fix: 遵守仓库硬约束(禁止修改 DSH 源码)——tasks.output 改为会话事件回放
- 回退对 harness peek 缝隙的依赖:输出查看不再调用注册表,改为从属主 会话事件日志回放 task_output 的 tool/call+tool/result 对(模型已读内容) - read:false 时面板提示「模型尚未读取」;'(no new output)' 增量与错误 结果过滤;截断上限不变;kill 走注册表原生 API(缺席时 503) - AGENTS.md 新增 §0 硬约束:禁止修改 DSH 源码、挂载只走 cordis.patch.yml + profile 机制;设计文档同步实施偏差 - 测试:tasks-routes 重写为回放语义(拼接/过滤/read 标志/截断/503), 视图新增 read:false 提示用例;全量 369 通过
This commit is contained in:
+14
-11
@@ -359,13 +359,13 @@ function CatalogRows({
|
||||
|
||||
/**
|
||||
* The shared output dock of the tasks section: ONE pane at the bottom of the
|
||||
* sidebar body (sticky, terminal-like) shows the SELECTED task's FULL
|
||||
* accumulated output via the host's NON-CONSUMING peek (`tasks.output`),
|
||||
* refreshed every {@link TASK_POLL_MS} while the task runs and the page is
|
||||
* visible. The model's own `task_output` cursor is never touched, so
|
||||
* watching a stream cannot steal the agent's bytes. A single dock — not a
|
||||
* panel per row — keeps the task list compact and stable when many tasks
|
||||
* are running.
|
||||
* sidebar body (sticky, terminal-like) shows the SELECTED task's output as
|
||||
* the MODEL has read it so far (replayed from the owner session's event
|
||||
* log), refreshed every {@link TASK_POLL_MS} while the task runs and the
|
||||
* page is visible. The model's `task_output` cursor is never touched — the
|
||||
* pane can never steal the agent's bytes, and it stays empty until the
|
||||
* agent reads the task. A single dock — not a panel per row — keeps the
|
||||
* task list compact and stable when many tasks are running.
|
||||
*/
|
||||
function TaskOutputPane(props: {
|
||||
ownerSessionId: string
|
||||
@@ -437,7 +437,9 @@ function TaskOutputPane(props: {
|
||||
<>
|
||||
{state.text.length > 0
|
||||
? <pre ref={preRef} className={css.tasksPanePre}>{state.text}</pre>
|
||||
: <div className={css.tasksPaneHint}>{t('taskNoOutput')}</div>}
|
||||
: state.read
|
||||
? <div className={css.tasksPaneHint}>{t('taskNoOutput')}</div>
|
||||
: <div className={css.tasksPaneHint}>{t('taskNotReadYet')}</div>}
|
||||
{state.truncated && <div className={css.tasksPaneHint}>{t('taskOutputTruncated')}</div>}
|
||||
</>
|
||||
)}
|
||||
@@ -448,9 +450,10 @@ function TaskOutputPane(props: {
|
||||
/**
|
||||
* The background-task section of the Subagent page: every task of the whole
|
||||
* current tree (main agent + subagents, owner-labeled), fed by the harness
|
||||
* `session/tasks` push mirror. Clicking a row feeds its output to the shared
|
||||
* bottom dock (non-consuming peek); live rows carry a two-click-confirm
|
||||
* kill button. Renders nothing while the tree has no tasks.
|
||||
* `session/tasks` push mirror. Clicking a row feeds its model-read output to
|
||||
* the shared bottom dock (event replay — never the model's cursor); live
|
||||
* rows carry a two-click-confirm kill button. Renders nothing while the
|
||||
* tree has no tasks.
|
||||
*/
|
||||
function TasksSection(props: {
|
||||
byId: SidebarSessionList['byId']
|
||||
|
||||
+12
-8
@@ -8,7 +8,6 @@
|
||||
*/
|
||||
import { encodeHtmlUrl } from '../html-route.ts'
|
||||
import type { BrowserProbeResult } from './browser.ts'
|
||||
import type { SidebarTaskStatus } from '../context-types.ts'
|
||||
|
||||
/** One wire failure. */
|
||||
export class SidebarApiError extends Error {
|
||||
@@ -61,13 +60,19 @@ export interface FsTextResult { kind: 'text'; content: string; truncated: boolea
|
||||
* `head` carries the first bytes (base64) for viewer detect sniffing. */
|
||||
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
|
||||
|
||||
/** One tasks.output response: the peeked full text (capped) plus the wire status. */
|
||||
/**
|
||||
* One tasks.output response: the output the MODEL has read so far for the
|
||||
* task (replayed from the owner session's event log — the model's
|
||||
* task_output cursor is never touched, so the pane can never steal the
|
||||
* agent's bytes). `read` is false until the model actually called
|
||||
* task_output for the task.
|
||||
*/
|
||||
export interface TaskOutputResult {
|
||||
text: string
|
||||
/** True when the host capped the text at its output limit. */
|
||||
truncated: boolean
|
||||
status: SidebarTaskStatus
|
||||
detail?: string
|
||||
/** Whether the model has read the task at least once. */
|
||||
read: boolean
|
||||
}
|
||||
|
||||
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
|
||||
@@ -156,10 +161,9 @@ export const api = {
|
||||
agentPtyClose: (uuid: string) =>
|
||||
call<{ ok: true }>('agent-pty.close', { uuid }),
|
||||
/**
|
||||
* Full accumulated output of one background task (the host's NON-CONSUMING
|
||||
* peek — the model's task_output cursor and report bit are untouched, so a
|
||||
* human watching a stream never steals the agent's bytes). The scope MUST
|
||||
* be the task's OWNER session (the tasks registry fences by session id).
|
||||
* The output the model has read so far for one background task (replayed
|
||||
* from the owner session's event log — never the model's task_output
|
||||
* cursor). The scope MUST be the task's OWNER session.
|
||||
*/
|
||||
taskOutput: (scope: SessionScope, id: string, signal?: AbortSignal) =>
|
||||
call<TaskOutputResult>('tasks.output', scopePayload(scope, { id }), signal),
|
||||
|
||||
@@ -194,6 +194,7 @@ const zh = {
|
||||
taskViewOutput: '查看输出',
|
||||
taskHideOutput: '收起输出',
|
||||
taskNoOutput: '暂无输出',
|
||||
taskNotReadYet: '模型尚未读取该任务的输出,读取后会显示在这里',
|
||||
taskOutputTruncated: '输出过长,已截断显示',
|
||||
taskOutputError: '输出读取失败',
|
||||
taskKill: '终止',
|
||||
@@ -390,6 +391,7 @@ const en: Record<keyof typeof zh, string> = {
|
||||
taskViewOutput: 'View output',
|
||||
taskHideOutput: 'Hide output',
|
||||
taskNoOutput: 'No output yet',
|
||||
taskNotReadYet: 'The model has not read this task\'s output yet; it will appear here once read',
|
||||
taskOutputTruncated: 'Output truncated',
|
||||
taskOutputError: 'Failed to read output',
|
||||
taskKill: 'Kill',
|
||||
|
||||
@@ -48,7 +48,15 @@ export interface SidebarSessionHeader {
|
||||
|
||||
/** The host session store face (`ctx.sessions.get(id)` returns the live session). */
|
||||
export interface SidebarSessionStore {
|
||||
get(id: string): { header: SidebarSessionHeader } | undefined
|
||||
get(id: string): {
|
||||
header: SidebarSessionHeader
|
||||
/**
|
||||
* The live session's append-only event log (immutable snapshot; absent
|
||||
* on sessions the runtime has not hydrated). Read-only access — the
|
||||
* tasks.output route replays `task_output` tool/result rows from it.
|
||||
*/
|
||||
events?: readonly SidebarSessionEvent[]
|
||||
} | undefined
|
||||
}
|
||||
|
||||
/** One loader entry's options slice (the connection row's resolved config). */
|
||||
@@ -176,14 +184,6 @@ export interface SidebarTaskView {
|
||||
|
||||
/** The host tasks registry face the sidebar routes touch (structural mirror of `TaskService`). */
|
||||
export interface SidebarTasksService {
|
||||
/**
|
||||
* Non-consuming full-output read (never advances the model's read cursor,
|
||||
* never marks the task reported). Throws for an unknown or foreign task.
|
||||
*/
|
||||
peek(id: string, caller?: SidebarAgent): {
|
||||
text: string
|
||||
snapshot: { status: SidebarTaskStatus; detail?: string }
|
||||
}
|
||||
/** Request cancellation; throws for an unknown or foreign task. */
|
||||
kill(id: string, caller?: SidebarAgent, reason?: string): 'requested' | 'already-finished'
|
||||
}
|
||||
|
||||
+13
-22
@@ -200,10 +200,11 @@ function buildApi(
|
||||
return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) }
|
||||
}
|
||||
// Background tasks: the LIST rides the harness's `session/tasks` push
|
||||
// mirror, so these routes only read output (a non-consuming peek — the
|
||||
// model's task_output cursor is never touched) and kill. A deployment
|
||||
// without the tasks registry downgrades to a 503, like the settings routes.
|
||||
const tasksApi: SidebarTasksRoutes | undefined = buildTasksApi(ctx, resolved.readLimit)
|
||||
// mirror, so these routes only replay output the model has read (from the
|
||||
// session's own event log — no DSH source is touched, the model's
|
||||
// task_output cursor is never consumed) and kill (the registry's stock
|
||||
// API). A deployment without the tasks registry downgrades kill to a 503.
|
||||
const tasksApi: SidebarTasksRoutes = buildTasksApi(ctx, resolved.readLimit)
|
||||
return {
|
||||
'session.cwd': (payload) => {
|
||||
const { sessionId, cwd } = cwdOf(payload)
|
||||
@@ -333,24 +334,14 @@ function buildApi(
|
||||
agentPtyRegistry.close(uuid)
|
||||
return { ok: true }
|
||||
},
|
||||
// Background tasks: read one task's FULL output (a non-consuming peek —
|
||||
// the model's task_output cursor and report bit are untouched, so a
|
||||
// human watching a stream never steals the agent's bytes), and kill one
|
||||
// task. The task LIST itself arrives through the harness's session/tasks
|
||||
// push mirror, so no list route exists. Access is fenced to the owning
|
||||
// session by the tasks registry (the caller resolves from sessionId).
|
||||
'tasks.output': (payload) => {
|
||||
if (tasksApi === undefined) {
|
||||
throw new SidebarError('task-error', 'the background-task registry is not mounted in this deployment', 503)
|
||||
}
|
||||
return tasksApi.output(payload)
|
||||
},
|
||||
'tasks.kill': (payload) => {
|
||||
if (tasksApi === undefined) {
|
||||
throw new SidebarError('task-error', 'the background-task registry is not mounted in this deployment', 503)
|
||||
}
|
||||
return tasksApi.kill(payload)
|
||||
},
|
||||
// Background tasks: read one task's output (a REPLAY of what the model
|
||||
// has read so far, from the owner session's event log — the model's
|
||||
// task_output cursor is never touched, so the human pane can never steal
|
||||
// the agent's bytes), and kill one task. The task LIST itself arrives
|
||||
// through the harness's session/tasks push mirror, so no list route
|
||||
// exists. Kill is fenced to the owning session by the tasks registry.
|
||||
'tasks.output': (payload) => tasksApi.output(payload),
|
||||
'tasks.kill': (payload) => tasksApi.kill(payload),
|
||||
// The side card preferences. The settings service is optional in the
|
||||
// composition; while absent the routes report undefined and the client
|
||||
// keeps the schema defaults. Writes are revision-guarded: a stale editor
|
||||
|
||||
+101
-39
@@ -1,50 +1,87 @@
|
||||
/**
|
||||
* Background-task routes of the /sidebar JSON API ('tasks.output' /
|
||||
* 'tasks.kill'). The task LIST itself needs no route: it arrives through the
|
||||
* 'tasks.kill'). The task LIST needs no route: it arrives through the
|
||||
* harness's `session/tasks` push mirror (`tasksBySession` in the sessions
|
||||
* list feed). These routes only:
|
||||
* list feed). The routes:
|
||||
*
|
||||
* - READ OUTPUT — a NON-CONSUMING peek (`ctx.tasks.peek`) that never advances
|
||||
* the model's `task_output` cursor nor claims the completion report, so a
|
||||
* human watching a stream cannot steal the agent's bytes;
|
||||
* - KILL tasks (`ctx.tasks.kill`) with a forwarded reason.
|
||||
*
|
||||
* Both are fenced by the owning session: the caller Agent is resolved live
|
||||
* from the requested sessionId (`ctx.agents.get`) and the tasks registry
|
||||
* refuses anything that session does not own. Registry errors map to a 404
|
||||
* `task-error` (unknown/foreign ids), so the client cannot distinguish
|
||||
* existence — matching the api-proxy's task-view secrecy.
|
||||
* - 'tasks.output' — REPLAYS the output the MODEL has read so far from the
|
||||
* owner session's OWN event log: `tool/call` rows of `task_output` name
|
||||
* the task via `arguments.task_id`, and the paired `tool/result` rows
|
||||
* carry the finalized content the model actually received. This touches
|
||||
* NO DSH source (no peek seam, no consuming read): the model's
|
||||
* `task_output` cursor is untouched by construction, and the pane stays
|
||||
* empty until the agent reads the task.
|
||||
* - 'tasks.kill' — the registry's stock `kill` (a pristine DSH API),
|
||||
* fenced by the owning session via the live agent caller. Absent registry
|
||||
* → 503, mirroring the settings routes' optional-service downgrade.
|
||||
*/
|
||||
import type { Context, SidebarTaskStatus } from './context-types.ts'
|
||||
import type { Context } from './context-types.ts'
|
||||
import { requireString, SidebarError } from './wire.ts'
|
||||
|
||||
/** The two background-task routes of the sidebar API. */
|
||||
export interface SidebarTasksRoutes {
|
||||
/** Full accumulated output of one task (non-consuming peek, capped). */
|
||||
output(payload: unknown): {
|
||||
text: string
|
||||
truncated: boolean
|
||||
status: SidebarTaskStatus
|
||||
detail?: string
|
||||
}
|
||||
/** The output the model has read so far for one task (event replay, capped). */
|
||||
output(payload: unknown): { text: string; truncated: boolean; read: boolean }
|
||||
/** Request cancellation of one task (live tasks flip to stopping). */
|
||||
kill(payload: unknown): { ok: true; outcome: 'requested' | 'already-finished' }
|
||||
}
|
||||
|
||||
/** The 'tool/result' message envelope inside a session event's data. */
|
||||
interface ToolResultMessageLike {
|
||||
source?: { kind?: unknown; callId?: unknown }
|
||||
content?: unknown
|
||||
}
|
||||
|
||||
/** One 'tool-result' content block (the inner blocks carry the text). */
|
||||
interface ToolResultBlockLike {
|
||||
type?: unknown
|
||||
content?: unknown
|
||||
isError?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the tasks routes bound to the plugin context. Returns undefined when
|
||||
* the deployment lacks the background-task registry (`ctx.tasks`) — the
|
||||
* calling route then reports a 503, mirroring the settings routes' optional
|
||||
* service downgrade.
|
||||
* @param ctx - host plugin context (tasks/agents read lazily at call time).
|
||||
* @param outputLimit - response cap for one output read in bytes; longer
|
||||
* Extract the plain text of a finalized tool result: the text blocks inside
|
||||
* the 'tool-result' block, joined with newlines. Error results and
|
||||
* non-text blocks contribute nothing.
|
||||
*/
|
||||
function resultText(message: ToolResultMessageLike): string | undefined {
|
||||
if (!Array.isArray(message.content)) return undefined
|
||||
const parts: string[] = []
|
||||
for (const block of message.content) {
|
||||
if (block === null || typeof block !== 'object') continue
|
||||
const candidate = block as ToolResultBlockLike
|
||||
if (candidate.type !== 'tool-result' || candidate.isError === true) continue
|
||||
const inner = candidate.content
|
||||
if (!Array.isArray(inner)) continue
|
||||
for (const item of inner) {
|
||||
if (item === null || typeof item !== 'object') continue
|
||||
const textItem = item as { type?: unknown; text?: unknown }
|
||||
if (textItem.type === 'text' && typeof textItem.text === 'string') {
|
||||
parts.push(textItem.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join('\n') : undefined
|
||||
}
|
||||
|
||||
/** Whether a task_output result carries no new output — the controller's
|
||||
* model-facing "(no new output)" body, noise for the human pane. */
|
||||
function isNoNewOutput(text: string): boolean {
|
||||
return text.startsWith('(no new output)')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the tasks routes bound to the plugin context. `output` reads only
|
||||
* the session store; `kill` reads the tasks/agents services lazily and
|
||||
* degrades to a 503 when the deployment lacks the registry.
|
||||
* @param ctx - host plugin context.
|
||||
* @param outputLimit - response cap for one output replay in bytes; longer
|
||||
* texts are sliced and flagged `truncated` (mirrors the fs.read cap).
|
||||
*/
|
||||
export function buildTasksApi(ctx: Context, outputLimit: number): SidebarTasksRoutes | undefined {
|
||||
export function buildTasksApi(ctx: Context, outputLimit: number): SidebarTasksRoutes {
|
||||
const tasks = ctx.get('tasks')
|
||||
if (tasks === undefined) return undefined
|
||||
const agents = ctx.get('agents')
|
||||
/** The live caller whose session id the fence compares against (absent → unowned only). */
|
||||
/** The live caller whose session id the registry fence compares against. */
|
||||
const callerOf = (sessionId: string) => agents?.get(sessionId)
|
||||
/** Registry refusals become a 404 task-error; unknown and foreign ids are indistinguishable. */
|
||||
const registryError = (error: unknown): SidebarError =>
|
||||
@@ -53,20 +90,45 @@ export function buildTasksApi(ctx: Context, outputLimit: number): SidebarTasksRo
|
||||
output(payload) {
|
||||
const sessionId = requireString(payload, 'sessionId')
|
||||
const id = requireString(payload, 'id')
|
||||
try {
|
||||
const read = tasks.peek(id, callerOf(sessionId))
|
||||
const text = read.text
|
||||
return {
|
||||
text: text.length > outputLimit ? text.slice(0, outputLimit) : text,
|
||||
truncated: text.length > outputLimit,
|
||||
status: read.snapshot.status,
|
||||
...read.snapshot.detail !== undefined ? { detail: read.snapshot.detail } : {},
|
||||
// One pass over the owner session's event log: tool/call rows first
|
||||
// map callId → task_id (a call always precedes its result), then each
|
||||
// matching tool/result contributes the finalized text the model saw.
|
||||
const session = ctx.sessions.get(sessionId)
|
||||
const taskOf = new Map<string, string>()
|
||||
const parts: string[] = []
|
||||
let read = false
|
||||
for (const event of session?.events ?? []) {
|
||||
if (event.type === 'tool/call') {
|
||||
const data = event.data as { name?: unknown; callId?: unknown; arguments?: unknown }
|
||||
if (data.name !== 'task_output' || typeof data.callId !== 'string') continue
|
||||
try {
|
||||
const args = JSON.parse(typeof data.arguments === 'string' ? data.arguments : '') as { task_id?: unknown }
|
||||
if (typeof args.task_id === 'string') taskOf.set(data.callId, args.task_id)
|
||||
} catch {
|
||||
// Malformed model arguments: not a task_output pair.
|
||||
}
|
||||
} else if (event.type === 'tool/result') {
|
||||
const message = (event.data as { message?: unknown }).message as ToolResultMessageLike | undefined
|
||||
if (message === undefined) continue
|
||||
const callId = message.source?.callId
|
||||
if (typeof callId !== 'string') continue
|
||||
if (taskOf.get(callId) !== id) continue
|
||||
read = true
|
||||
const text = resultText(message)
|
||||
if (text !== undefined && !isNoNewOutput(text)) parts.push(text)
|
||||
}
|
||||
} catch (error) {
|
||||
throw registryError(error)
|
||||
}
|
||||
const text = parts.join('\n')
|
||||
return {
|
||||
text: text.length > outputLimit ? text.slice(0, outputLimit) : text,
|
||||
truncated: text.length > outputLimit,
|
||||
read,
|
||||
}
|
||||
},
|
||||
kill(payload) {
|
||||
if (tasks === undefined) {
|
||||
throw new SidebarError('task-error', 'the background-task registry is not mounted in this deployment', 503)
|
||||
}
|
||||
const sessionId = requireString(payload, 'sessionId')
|
||||
const id = requireString(payload, 'id')
|
||||
const record = payload as { reason?: unknown } | null
|
||||
|
||||
Reference in New Issue
Block a user