Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/verification/issue-370-completed-context-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# #370 完成任务的后续上下文导航

## 目的

已完成任务的独立后续对话没有保存来源关系,原会话只能回到旧执行,无法沿已有上下文继续查看。补充明确的历史关联与导航,不创建会话或模型请求,不修改完成事实、规格、Execution ID、owner、epoch 或当前处理会话。

## 支持的登记命令

复用现有 operator 命令 `task.associate-session`,增加可选 `historicalSource`:

```json
{
"type": "task.associate-session",
"commandId": "unique-command-id",
"ref": { "projectId": "project-id", "taskId": "task-id" },
"expectedVersion": 36,
"session": { "runtimeId": "runtime-id", "sessionId": "existing-successor-id" },
"purpose": "related",
"historicalSource": { "runtimeId": "runtime-id", "sessionId": "exact-source-id" }
}
```

任务须有完成记录;来源须属于完成记录指定执行的当前或退休会话;目标须为同一个执行工作目录内已有的独立会话,且不能占用其他执行的当前、退休或已保留的接续会话(`session-in-use`)。重复同一关系不增加任务版本,原有 related 关联可补充来源,不带新字段的旧调用保持兼容。关联只保存导航关系,不证明交接材料已读取,也不授予任务执行权。带 `historicalSource` 的关联不进入原执行的观察或执行图根集合,因此后续对话的运行、工具和作业不阻塞原执行结算;普通 related/review 仍按原规则观察。

`session.work` 保留原有 `purpose`、执行与代次,另外投影 `historicalContexts`。原生会话页头、A²D² 接续页和已完成任务概览提供“后续上下文 / 进入会话”,多个上下文按序号区分,标签不显示会话 ID。点击重新读取准确 Task 的已登记关系,仅调用宿主打开目标 Session。

## 步骤与记录

1. 使用真实 `WorkApplication` 创建并完成任务,给原执行登记已有后续上下文。重载存储核验来源投影、目标关联与完成/规格/执行完整相等。
2. 覆盖活动任务、错误来源、目标自指、错误工作目录被拒绝;覆盖普通旧关联、补充来源、重复调用和旧格式调用。
3. 挂载生产组件,分别点击原生接续页、页头和完成任务入口,确认只打开准确目标且未发送任何工作命令。没有登记关系时拒绝导航,不替换成旧执行。
4. 红灯 `docs/artifacts/370-2026-11-08/red.log`:真实任务关联已存在,但生产展示没有“后续上下文”。受影响 6 个测试文件共 81 项通过;`pnpm typecheck` 与 `pnpm build` 通过。原始日志位于本地 `docs/artifacts/370-2026-11-08/`,未纳入 Git。

5. 修复 review 确认的跨执行占用与结算范围问题:三种占用状态先红后绿,真实 Agent 的运行工具及真实作业验证历史关联不阻塞原执行;普通 related 仍被观察。最小 3 个测试文件共 22 项通过,类型检查通过。原始记录为本地 `/tmp/370-occupancy-red.log`、`/tmp/370-observation-red.log`、`/tmp/370-remediation-tests.log` 和 `/tmp/370-remediation-typecheck.log`。

6. 开发环境页面发现按钮未继承操作样式、输入区提示与主内容重复。历史入口复用现有 `taskActions` 与 `row` 样式;历史上下文不再单独生成输入区提示,原有正式交接提示保持。3 个相关 UI 测试文件共 58 项通过,类型检查通过。原始记录为本地 `/tmp/370-visual-tests.log`、`/tmp/370-visual-typecheck.log` 与 `/tmp/370-visual-client-typecheck.log`;测试 mock 的字面量类型修正后,客户端类型检查已单独通过,宿主检查复用同轮通过结果。

本报告只记录代码级验证。实际开发环境页面截图、发布、既有生产后续会话登记与最终验收由主 agent 继续执行;本分支未部署、未重启服务、未修改生产数据。
9 changes: 9 additions & 0 deletions src/client/execution-components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ import type { ExecutionGraph, ExecutionJob, ObservedSession } from '../work-exec
import { continuationInFlight } from '../work-execution/state.ts'
import styles from './execution-components.module.css'

/** Explicit historical links carry no execution authority. */
export function HistoricalContextLinks({ sessions, onEnter, busy = false }: { sessions?: SessionRef[]; onEnter: (session: SessionRef) => void; busy?: boolean }) {
if (!sessions?.length) return null
return <span className={`${styles.taskActions} ${styles.row}`}>{sessions.map((session, index) => <span key={`${session.runtimeId}/${session.sessionId}`}>
<span>后续上下文{sessions.length > 1 ? index + 1 : ''} </span>
<button type="button" disabled={busy} onClick={() => onEnter(session)}>进入会话</button>
</span>)}</span>
}

export interface ExecutionNavigation {
onReadIssue?: (issue: import('../work-sources/types.ts').GitHubIssueReference) => void
onOpenTask?: (ref: TaskRef) => void
Expand Down
19 changes: 14 additions & 5 deletions src/client/execution-native.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import type { AssociatedWork, ContinuationPolicy, WorkCompleted } from '../work-types.ts'
import type { AssociatedWork, ContinuationPolicy, SessionRef, WorkCompleted } from '../work-types.ts'
import type { ExecutionGraph } from '../work-execution/observation.ts'
import type { WorkPanelController } from './work-panel-controller.ts'
import { ExecutionContinuation } from './execution-components.tsx'
import { ExecutionContinuation, HistoricalContextLinks } from './execution-components.tsx'
import styles from './execution-components.module.css'

type ExecutionDetail = WorkCompleted & { executionGraph?: ExecutionGraph }
Expand All @@ -26,11 +26,11 @@ export function ExecutionSessionWorkEntry({ controller, sessionId, blank, placem
if (!work.length && !error) return null
if (placement === 'header' && blank) return null
if (placement === 'dock' && !blank && !work.some(item => item.purpose === 'history')) return null
const enter = async (item: AssociatedWork) => {
const enter = async (item: AssociatedWork, session?: SessionRef) => {
const abort = scope.current
if (!abort || abort.signal.aborted || opening) return
setOpening(true); setNavigationError(null)
try { await controller.openCurrentTask(item.ref, abort.signal) }
try { await (session ? controller.openHistoricalContext(item.ref, session, abort.signal) : controller.openCurrentTask(item.ref, abort.signal)) }
catch (error) { if (!abort.signal.aborted) setNavigationError(error instanceof Error ? error.message : String(error)) }
finally { if (!abort.signal.aborted) setOpening(false) }
}
Expand All @@ -43,7 +43,7 @@ export function ExecutionSessionWorkEntry({ controller, sessionId, blank, placem
</div>
return <details className={`${styles.entry} ${placement === 'dock' ? styles.dock : ''}`}><summary>关联任务{work.length ? ` · ${work.length}` : ''}</summary><div className={styles.popover}>
{error && <p role="status">关联读取失败:{error}</p>}
{work.map(item => <article key={`${item.ref.projectId}/${item.ref.taskId}`}><p>{item.projectName} / {item.title}</p><p>{item.purpose === 'control' ? '当前协调' : item.purpose === 'history' ? '已交接 · 可继续对话' : item.purpose === 'review' ? '评审关联' : '工作关联'}</p><div className={styles.row}><button type="button" onClick={() => void controller.revealTask(item.ref)}>工作详情</button>{item.purpose === 'history' && <button type="button" disabled={opening} onClick={() => void enter(item)}>进入会话</button>}</div></article>)}
{work.map(item => <article key={`${item.ref.projectId}/${item.ref.taskId}`}><p>{item.projectName} / {item.title}</p><p>{item.purpose === 'control' ? '当前协调' : item.purpose === 'history' ? '已交接 · 可继续对话' : item.purpose === 'review' ? '评审关联' : '工作关联'}</p><div className={styles.row}><button type="button" onClick={() => void controller.revealTask(item.ref)}>工作详情</button>{item.purpose === 'history' && <button type="button" disabled={opening} onClick={() => void enter(item)}>进入会话</button>}</div><HistoricalContextLinks sessions={item.historicalContexts} busy={opening} onEnter={session => void enter(item, session)}/></article>)}
{navigationError && <p role="alert">{navigationError}</p>}
</div></details>
}
Expand Down Expand Up @@ -90,6 +90,14 @@ function NativeTaskContinuation({ controller, sessionId, work }: { controller: W
} catch (error) { if (!scope.signal.aborted) setError(error instanceof Error ? error.message : String(error)) }
finally { if (!scope.signal.aborted) setBusy(false) }
}
const enterContext = async (session: SessionRef) => {
const scope = lifetime.current
if (busy || !scope || scope.signal.aborted) return
setBusy(true); setError(null)
try { await controller.openHistoricalContext(work.ref, session, scope.signal) }
catch (error) { if (!scope.signal.aborted) setError(error instanceof Error ? error.message : String(error)) }
finally { if (!scope.signal.aborted) setBusy(false) }
}
const completed = Boolean(detail?.task?.completion || detail?.progress?.completion || detail?.progress?.processingState === 'completed')
const purpose = work.purpose === 'control' ? '当前协调' : work.purpose === 'history' ? '已交接 · 可继续对话' : work.purpose === 'review' ? '评审关联' : '工作关联'
return <section className={styles.nativeView}>
Expand All @@ -107,6 +115,7 @@ function NativeTaskContinuation({ controller, sessionId, work }: { controller: W
{error && <p role="alert">{error}</p>}
</aside>
<div className={styles.configPane}>
{completed && <HistoricalContextLinks sessions={detail?.task?.sessions.filter(binding => binding.historicalSource?.sessionId === sessionId && binding.historicalSource.runtimeId === detail.execution?.resource.runtimeId).map(binding => binding.session)} busy={busy} onEnter={session => void enterContext(session)}/>}
{detail ? <ExecutionContinuation graph={detail.executionGraph} execution={detail.execution} policy={policy} busy={busy} completed={completed} {...(work.purpose === 'control' && !completed && detail.execution?.session.sessionId === sessionId ? { onContinue: () => void controlWork('continue'), onRecover: () => void controlWork('reconcile'), onResume: () => void controlWork('resume') } : {})}/> : !error && <p role="status">正在读取接续事实…</p>}
</div>
</div>
Expand Down
12 changes: 11 additions & 1 deletion src/client/work-panel-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1012,7 +1012,9 @@ export class WorkPanelController {
}
private currentTaskRead?: AbortController
/** Only an explicit click enters the freshly observed, verified control Session. */
async openCurrentTask(ref: TaskRef, signal?: AbortSignal): Promise<void> {
openCurrentTask(ref: TaskRef, signal?: AbortSignal): Promise<void> { return this.openTaskSession(ref, signal) }
openHistoricalContext(ref: TaskRef, session: SessionRef, signal?: AbortSignal): Promise<void> { return this.openTaskSession(ref, signal, session) }
private async openTaskSession(ref: TaskRef, signal?: AbortSignal, historicalContext?: SessionRef): Promise<void> {
if (signal?.aborted) return
this.currentTaskRead?.abort()
const abort = new AbortController(); this.currentTaskRead = abort
Expand All @@ -1022,6 +1024,14 @@ export class WorkPanelController {
try {
const detail = await this.client.query({ type: 'task.detail', ref }, abort.signal)
if (abort.signal.aborted || revision !== this.revision || sourceRevision !== this.sourceRevision) return
if (historicalContext) {
const task = detail.task
if (task?.id !== ref.taskId || task.projectId !== ref.projectId || !task.completion
|| !task.sessions.some(binding => binding.purpose === 'related' && binding.historicalSource
&& binding.session.runtimeId === historicalContext.runtimeId && binding.session.sessionId === historicalContext.sessionId)) throw Error('后续上下文暂不可进入,请刷新工作详情。')
this.openSession(historicalContext)
return
}
const execution = detail.execution
if (detail.task?.id !== ref.taskId || detail.task.projectId !== ref.projectId || !execution?.actual
|| execution.ref.taskId !== ref.taskId || execution.ref.projectId !== ref.projectId
Expand Down
17 changes: 10 additions & 7 deletions src/client/work-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ListQueryBar } from './list-query-bar.tsx'
import { IssueMarkdown } from './issue-markdown.tsx'
import { PanelIcon } from './icons.tsx'
import { GitHubAccountControl } from './github-account.tsx'
import { ExecutionSessions, ExecutionContinuation } from './execution-components.tsx'
import { ExecutionSessions, ExecutionContinuation, HistoricalContextLinks } from './execution-components.tsx'
import { CompletionStatus } from './completion-status.tsx'
import { ContinuousDelegation } from './continuous-delegation.tsx'
import { SourceConnect } from './source-connect.tsx'
Expand All @@ -14,7 +14,7 @@ import { SessionCell, sessionOf } from './session-cell.tsx'
import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
import type { FormEvent, ReactNode } from 'react'
import type { DetailTab, WorkPanelController } from './work-panel-controller.ts'
import type { AssociatedWork, WorkExecution, WorkTask, WorkExecutionOptions, ContinuousWorkRule } from '../work-types.ts'
import type { AssociatedWork, SessionRef, WorkExecution, WorkTask, WorkExecutionOptions, ContinuousWorkRule } from '../work-types.ts'
import type { GitHubIssue } from '../work-sources/types.ts'
import { message } from './work-panel-controller.ts'
import styles from './work-panel.module.css'
Expand Down Expand Up @@ -249,9 +249,12 @@ function executionLabel(state: WorkExecution['state'], progress?: import('../wor
function CurrentExecution({controller}:{controller:WorkPanelController}) {
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot)
const { execution, executionRuntime, progress } = state.detail ?? {}, { options, selection, busy } = state.startup
const [contextError,setContextError]=useState<string|null>(null)
const completed = Boolean(state.detail?.task?.completion)||completedProgress(progress)
if(!execution&&!options&&!completed)return null
return <Section title="会话" className={!execution&&!options?styles.idleExecution:undefined}>
{completed && <HistoricalContextLinks sessions={state.detail?.task?.sessions.filter(binding=>binding.historicalSource).map(binding=>binding.session)} busy={busy} onEnter={session=>void controller.openHistoricalContext({projectId:state.detail!.task!.projectId,taskId:state.detail!.task!.id},session).catch(error=>setContextError(message(error)))}/>}
{contextError && <ErrorText error={contextError}/>}
{execution ? <>
<p>{(()=>{const task=state.detail?.task;if(!task)return null;const counts=controller.sessionCounts(task);return `${counts.total} 个会话 · 当前 ${counts.current} · 历史 ${counts.historical} · 其他关联 ${counts.associated}`})()}</p><p>执行状态:{execution.control?.action === 'end' ? (execution.control.status === 'applied' ? '队列已结束,业务验收未确认' : '正在结束队列处理') : state.detail?.task?.completion?({pending:'等待执行收口',settled:'执行已安全收口',error:'执行收口失败'})[state.detail.task.completion.closeout.status]:executionLabel(execution.state,progress,execution.control?.status)}{executionRuntime ? ` · 会话:${({running:'正在运行',idle:'空闲',inactive:'未激活'})[executionRuntime]}` : ''}</p>
{execution.progress && <p>工作进展:{execution.progress}</p>}
Expand Down Expand Up @@ -350,8 +353,8 @@ function activityLabel(type:string):string {return ({'task.create':'创建工作
function ErrorText({error}:{error:string}) {return error ? <p role="alert" className={styles.failure}>{error}</p>:null}

/** Present native work links without owning navigation or execution side effects. */
export function SessionWorkLinks({work,onReveal,onEnter,opening=false}:{work:AssociatedWork[];onReveal:(ref:AssociatedWork['ref'])=>void;onEnter:(ref:AssociatedWork['ref'])=>void;opening?:boolean}) {
return <>{work.map(item=><span key={`${item.ref.projectId}/${item.ref.taskId}`}><button onClick={()=>onReveal(item.ref)} title="查看工作与进展">{item.projectName} / {item.title} · 工作与进展</button>{item.purpose==='history'&&<><span> 处理会话已切换;内容投递状态见「A²D² 接续」 · </span><button disabled={opening} onClick={()=>onEnter(item.ref)}>{opening?'正在核对…':'进入会话'}</button></>}</span>)}</>
export function SessionWorkLinks({work,onReveal,onEnter,opening=false}:{work:AssociatedWork[];onReveal:(ref:AssociatedWork['ref'])=>void;onEnter:(ref:AssociatedWork['ref'],session?:SessionRef)=>void;opening?:boolean}) {
return <>{work.map(item=><span key={`${item.ref.projectId}/${item.ref.taskId}`}><button onClick={()=>onReveal(item.ref)} title="查看工作与进展">{item.projectName} / {item.title} · 工作与进展</button>{item.purpose==='history'&&<><span> 处理会话已切换;内容投递状态见「A²D² 接续」 · </span><button disabled={opening} onClick={()=>onEnter(item.ref)}>{opening?'正在核对…':'进入会话'}</button></>}<HistoricalContextLinks sessions={item.historicalContexts} busy={opening} onEnter={session=>onEnter(item.ref,session)}/></span>)}</>
}
/** Mounted native entries observe role changes; polling never opens a Session or touches drafts. */
export function SessionWorkEntry({controller, sessionId}: {controller: WorkPanelController; sessionId: string}) {
Expand All @@ -365,16 +368,16 @@ export function SessionWorkEntry({controller, sessionId}: {controller: WorkPanel
const stop=controller.watchSessionWork(sessionId,setWork,setError)
return () => {abort.abort();stop()}
},[controller,sessionId])
async function enter(ref:AssociatedWork['ref']) {
async function enter(ref:AssociatedWork['ref'],session?:SessionRef) {
if(!scope||scope.signal.aborted||opening)return
setOpening(true);setNavigationError(null)
try {await controller.openCurrentTask(ref,scope.signal)}catch(error){if(!scope.signal.aborted)setNavigationError(message(error))}
try {await (session?controller.openHistoricalContext(ref,session,scope.signal):controller.openCurrentTask(ref,scope.signal))}catch(error){if(!scope.signal.aborted)setNavigationError(message(error))}
finally {if(!scope.signal.aborted)setOpening(false)}
}
if (!work.length&&!error&&!navigationError) return null
return <div className={styles.sessionEntry}>
{error&&<span title={error}>工作关联读取失败</span>}
{SessionWorkLinks({work,onReveal:ref=>void controller.revealTask(ref),onEnter:ref=>void enter(ref),opening})}
{SessionWorkLinks({work,onReveal:ref=>void controller.revealTask(ref),onEnter:(ref,session)=>void enter(ref,session),opening})}
{navigationError&&<span role="alert">{navigationError}</span>}
</div>
}
2 changes: 1 addition & 1 deletion src/work-execution/dsh-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ export class DshWorkExecutionHost implements WorkExecutionHost {
bindings = await this.observationScope(execution)
} catch { blockers.push({ code: 'execution-scope-unavailable', session: execution.session }); limitations.add('无法从权威 Task 记录证明完整关联范围,结算状态未知。') }
}
for (const binding of bindings) roots.set(sessionKey(binding.session), { session: binding.session, role: binding.purpose })
for (const binding of bindings.filter(item => !item.historicalSource)) roots.set(sessionKey(binding.session), { session: binding.session, role: binding.purpose })
for (const old of execution.retired ?? []) roots.set(sessionKey(old.session), { session: old.session, role: 'retired' })
roots.set(sessionKey(execution.session), { session: execution.session, role: 'current' })
const receivedChildren = new Map<string, number>()
Expand Down
Loading
Loading