diff --git a/docs/verification/issue-370-completed-context-navigation.md b/docs/verification/issue-370-completed-context-navigation.md new file mode 100644 index 00000000..f1fb3e7b --- /dev/null +++ b/docs/verification/issue-370-completed-context-navigation.md @@ -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 继续执行;本分支未部署、未重启服务、未修改生产数据。 diff --git a/src/client/execution-components.tsx b/src/client/execution-components.tsx index e34ec200..ffd60ffc 100644 --- a/src/client/execution-components.tsx +++ b/src/client/execution-components.tsx @@ -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 {sessions.map((session, index) => + 后续上下文{sessions.length > 1 ? index + 1 : ''} + + )} +} + export interface ExecutionNavigation { onReadIssue?: (issue: import('../work-sources/types.ts').GitHubIssueReference) => void onOpenTask?: (ref: TaskRef) => void diff --git a/src/client/execution-native.tsx b/src/client/execution-native.tsx index 455b34fa..b0c62976 100644 --- a/src/client/execution-native.tsx +++ b/src/client/execution-native.tsx @@ -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 } @@ -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) } } @@ -43,7 +43,7 @@ export function ExecutionSessionWorkEntry({ controller, sessionId, blank, placem return
关联任务{work.length ? ` · ${work.length}` : ''}
{error &&

关联读取失败:{error}

} - {work.map(item =>

{item.projectName} / {item.title}

{item.purpose === 'control' ? '当前协调' : item.purpose === 'history' ? '已交接 · 可继续对话' : item.purpose === 'review' ? '评审关联' : '工作关联'}

{item.purpose === 'history' && }
)} + {work.map(item =>

{item.projectName} / {item.title}

{item.purpose === 'control' ? '当前协调' : item.purpose === 'history' ? '已交接 · 可继续对话' : item.purpose === 'review' ? '评审关联' : '工作关联'}

{item.purpose === 'history' && }
void enter(item, session)}/>
)} {navigationError &&

{navigationError}

}
} @@ -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
@@ -107,6 +115,7 @@ function NativeTaskContinuation({ controller, sessionId, work }: { controller: W {error &&

{error}

}
+ {completed && binding.historicalSource?.sessionId === sessionId && binding.historicalSource.runtimeId === detail.execution?.resource.runtimeId).map(binding => binding.session)} busy={busy} onEnter={session => void enterContext(session)}/>} {detail ? void controlWork('continue'), onRecover: () => void controlWork('reconcile'), onResume: () => void controlWork('resume') } : {})}/> : !error &&

正在读取接续事实…

}
diff --git a/src/client/work-panel-controller.ts b/src/client/work-panel-controller.ts index e4ed90e7..05101443 100644 --- a/src/client/work-panel-controller.ts +++ b/src/client/work-panel-controller.ts @@ -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 { + openCurrentTask(ref: TaskRef, signal?: AbortSignal): Promise { return this.openTaskSession(ref, signal) } + openHistoricalContext(ref: TaskRef, session: SessionRef, signal?: AbortSignal): Promise { return this.openTaskSession(ref, signal, session) } + private async openTaskSession(ref: TaskRef, signal?: AbortSignal, historicalContext?: SessionRef): Promise { if (signal?.aborted) return this.currentTaskRead?.abort() const abort = new AbortController(); this.currentTaskRead = abort @@ -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 diff --git a/src/client/work-panel.tsx b/src/client/work-panel.tsx index be3d46cd..7977de65 100644 --- a/src/client/work-panel.tsx +++ b/src/client/work-panel.tsx @@ -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' @@ -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' @@ -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(null) const completed = Boolean(state.detail?.task?.completion)||completedProgress(progress) if(!execution&&!options&&!completed)return null return
+ {completed && 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 && } {execution ? <>

{(()=>{const task=state.detail?.task;if(!task)return null;const counts=controller.sessionCounts(task);return `${counts.total} 个会话 · 当前 ${counts.current} · 历史 ${counts.historical} · 其他关联 ${counts.associated}`})()}

执行状态:{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]}` : ''}

{execution.progress &&

工作进展:{execution.progress}

} @@ -350,8 +353,8 @@ function activityLabel(type:string):string {return ({'task.create':'创建工作 function ErrorText({error}:{error:string}) {return error ?

{error}

: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=>{item.purpose==='history'&&<> 处理会话已切换;内容投递状态见「A²D² 接续」 · })} +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=>{item.purpose==='history'&&<> 处理会话已切换;内容投递状态见「A²D² 接续」 · }onEnter(item.ref,session)}/>)} } /** Mounted native entries observe role changes; polling never opens a Session or touches drafts. */ export function SessionWorkEntry({controller, sessionId}: {controller: WorkPanelController; sessionId: string}) { @@ -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
{error&&工作关联读取失败} - {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&&{navigationError}}
} diff --git a/src/work-execution/dsh-host.ts b/src/work-execution/dsh-host.ts index 56a5aef9..2339d02f 100644 --- a/src/work-execution/dsh-host.ts +++ b/src/work-execution/dsh-host.ts @@ -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() diff --git a/src/work-execution/observation.ts b/src/work-execution/observation.ts index 1121d0e5..4992243a 100644 --- a/src/work-execution/observation.ts +++ b/src/work-execution/observation.ts @@ -89,7 +89,7 @@ export function buildExecutionGraph(input: { task: WorkTask; execution?: WorkExe const ref = { projectId: task.projectId, taskId: task.id } if (execution && !sameTask(execution.ref, ref)) throw new Error('Execution does not belong to this Task') const bound = new Map() - for (const association of task.sessions) bound.set(sessionKey(association.session), { session: association.session, role: association.purpose, status: 'unknown', children: [], jobs: [] }) + for (const association of task.sessions.filter(item => !item.historicalSource)) bound.set(sessionKey(association.session), { session: association.session, role: association.purpose, status: 'unknown', children: [], jobs: [] }) for (const old of execution?.retired ?? []) bound.set(sessionKey(old.session), { session: old.session, role: 'retired', status: 'unknown', children: [], jobs: [] }) if (execution) bound.set(sessionKey(execution.session), { session: execution.session, role: 'current', status: 'unknown', children: [], jobs: [] }) // Only an observation of an explicitly bound root or its native descendants belongs here. diff --git a/src/work-schema.ts b/src/work-schema.ts index be328dc5..76f99860 100644 --- a/src/work-schema.ts +++ b/src/work-schema.ts @@ -29,7 +29,7 @@ export const taskSchema = z.object({ completion: completionSchema.optional(), childCompletions: z.array(childCompletionSchema).optional(), specification: specificationSchema, specificationHistory: z.array(specificationSchema), breakdown: z.array(block), parent: taskRefSchema.optional(), children: z.array(taskRefSchema), - sessions: z.array(z.object({ session: sessionRefSchema, purpose: z.enum(['related', 'review']) }).strict()), + sessions: z.array(z.object({ session: sessionRefSchema, purpose: z.enum(['related', 'review']), historicalSource: sessionRefSchema.optional() }).strict()), activity: z.array(z.object({ commandId: referenceId, type: referenceId, version: z.number().int().positive() }).strict()), }).strict() export const continuationContentSchema = z.object({ body: text, decisions: text, evidence: text, unfinished: text, next: text }).strict() @@ -138,7 +138,7 @@ export const commandSchema = z.discriminatedUnion('type', [ z.object({ ...change, type: z.literal('task.update-spec'), goal: text, specification: text, acceptance: content.acceptance }).strict(), z.object({ ...change, type: z.literal('task.replace-breakdown'), blocks: z.array(block).max(1000) }).strict(), z.object({ ...change, type: z.literal('task.link-child'), child: taskRefSchema, childExpectedVersion: z.number().int().positive() }).strict(), - z.object({ ...change, type: z.literal('task.associate-session'), session: sessionRefSchema, purpose: z.enum(['related', 'review']) }).strict(), + z.object({ ...change, type: z.literal('task.associate-session'), session: sessionRefSchema, purpose: z.enum(['related', 'review']), historicalSource: sessionRefSchema.optional() }).strict(), z.object({ ...change, type: z.literal('legacy.map'), address: legacyAddressSchema }).strict(), z.object({ ...change, type: z.literal('task.start'), agentId: id, resource: resourceRefSchema, session: sessionRefSchema.optional(), agentPreset: referenceId, model: modelSchema, permissionPreset: referenceId }).strict(), diff --git a/src/work-types.ts b/src/work-types.ts index 436f77ff..6af0c3ca 100644 --- a/src/work-types.ts +++ b/src/work-types.ts @@ -37,7 +37,7 @@ export interface ContinuationOperation { export interface Project { id: string; name: string; version: number; defaultAgentId: string; resources: ResourceRef[]; continuationPolicy?: ContinuationPolicy; executionDefaults?: Partial> } export interface WorkSpecification { version: number; text: string; acceptance: string[]; missing?: ('body' | 'acceptance')[] } export interface BreakdownBlock { id: string; title: string; description: string; status?: WorkStage; evidence?: string[]; child?: TaskRef } -export interface SessionAssociation { session: SessionRef; purpose: 'related' | 'review' } +export interface SessionAssociation { session: SessionRef; purpose: 'related' | 'review'; historicalSource?: SessionRef } export interface WorkActivity { commandId: string; type: string; version: number } export interface WorkTask { id: string; projectId: string; title: string; goal: string; version: number; agentId: string @@ -149,7 +149,7 @@ export type WorkCommand = | (TaskChange & { type: 'task.update-spec'; goal: string; specification: string; acceptance: string[] }) | (TaskChange & { type: 'task.replace-breakdown'; blocks: BreakdownBlock[] }) | (TaskChange & { type: 'task.link-child'; child: TaskRef; childExpectedVersion: number }) - | (TaskChange & { type: 'task.associate-session'; session: SessionRef; purpose: 'related' | 'review' }) + | (TaskChange & { type: 'task.associate-session'; session: SessionRef; purpose: 'related' | 'review'; historicalSource?: SessionRef }) | (TaskChange & { type: 'legacy.map'; address: LegacyAddress }) | (Command & ExecutionRef & { type: 'task.configure-continuation'; owner: string; epoch: number; policy: ContinuationPolicy }) | (Command & ExecutionRef & { type: 'task.continue'; owner: string; epoch: number }) @@ -183,7 +183,7 @@ export type WorkQuery = | { type: 'session.candidates'; projectId: string } | { type: 'session.work'; session: SessionRef } | { type: 'legacy.resolve'; address: LegacyAddress } -export interface AssociatedWork { ref: TaskRef; title: string; projectName: string; purpose: 'related' | 'review' | 'control' | 'history'; executionId?: string; epoch?: number; currentSession?: SessionRef } +export interface AssociatedWork { ref: TaskRef; title: string; projectName: string; purpose: 'related' | 'review' | 'control' | 'history'; executionId?: string; epoch?: number; currentSession?: SessionRef; historicalContexts?: SessionRef[] } /** Completed fields correspond to command/query; task.detail includes related children and parent. */ export interface SourceResultFields { githubAccount?: import('./work-sources/types.ts').GitHubAccount diff --git a/src/work.ts b/src/work.ts index c7771ddd..6d0a1a4c 100644 --- a/src/work.ts +++ b/src/work.ts @@ -1825,7 +1825,20 @@ export class WorkApplication { } if (command.type === 'task.associate-session') { const project = state.projects.find(item => item.id === command.ref.projectId) - if (!project || !(await this.candidates(project)).some(item => sameSession(item.ref, command.session))) { + const task = taskAt(state, command.ref)! + const execution = state.executions.find(item => item.id === task.completion?.executionId && sameTask(item.ref, command.ref)) + if (command.historicalSource && (!isTaskCompleted(task) || command.purpose !== 'related' || !execution + || !(sameSession(execution.session, command.historicalSource) || execution.retired?.some(item => sameSession(item.session, command.historicalSource!))) + || sameSession(command.session, command.historicalSource) || sameSession(command.session, execution.session) + || execution.retired?.some(item => sameSession(item.session, command.session)))) { + return failed('invalid-historical-context', 'Historical context requires a completed Task and an exact execution source with a separate related Session') + } + if (command.historicalSource && state.executions.some(item => item.id !== execution!.id && (sameSession(item.session, command.session) + || item.retired?.some(old => sameSession(old.session, command.session)) || (item.continuation && sameSession(item.continuation.successor, command.session))))) { + return failed('session-in-use', 'Historical context Session belongs to another execution') + } + if (!project || !(await this.candidates(project)).some(item => sameSession(item.ref, command.session) + && (!command.historicalSource || sameResource(item.resource, execution!.resource)))) { return failed('incompatible-session', 'Session is not a compatible member of this project resource') } } @@ -2096,12 +2109,16 @@ export class WorkApplication { const existing = task.sessions.find(item => sameSession(item.session, command.session)) if (existing) { if (existing.purpose !== command.purpose) return failed('association-purpose-conflict', 'Session is already associated with another purpose') + if (command.historicalSource) { + if (existing.historicalSource && !sameSession(existing.historicalSource, command.historicalSource)) return failed('association-source-conflict', 'Session already has another historical source') + if (!existing.historicalSource) { existing.historicalSource = command.historicalSource; this.changed(task, command) } + } return { status: 'completed', task } } if (state.tasks.some(item => item.projectId !== projectId && item.sessions.some(binding => sameSession(binding.session, command.session)))) { return failed('cross-project-reference', 'Session is associated with work in another project') } - task.sessions.push({ session: command.session, purpose: command.purpose }) + task.sessions.push({ session: command.session, purpose: command.purpose, ...(command.historicalSource ? { historicalSource: command.historicalSource } : {}) }) this.changed(task, command) return { status: 'completed', task } } @@ -2262,7 +2279,11 @@ export class WorkApplication { ref: item.ref, title: taskAt(state, item.ref)!.title, projectName: state.projects.find(project => project.id === item.ref.projectId)!.name, purpose: 'control' as const, executionId: item.id, epoch: item.epoch, })), - ] } + ].map(item => { + const task = taskAt(state, item.ref)! + const contexts = isTaskCompleted(task) ? task.sessions.filter(binding => binding.historicalSource && sameSession(binding.historicalSource, query.session)).map(binding => binding.session) : [] + return contexts.length ? { ...item, historicalContexts: contexts } : item + }) } if (query.type === 'session.candidates') { const project = state.projects.find(item => item.id === query.projectId) return project ? { status: 'completed', candidates: await this.candidates(project) } : failed('unknown-project', 'Project does not exist') diff --git a/tests/client/execution-components.spec.ts b/tests/client/execution-components.spec.ts index a2650633..7d6d2e40 100644 --- a/tests/client/execution-components.spec.ts +++ b/tests/client/execution-components.spec.ts @@ -5,7 +5,7 @@ import { expect, it } from 'vitest' import { mkdir, readFile, writeFile } from 'node:fs/promises' import { resolve } from 'node:path' import styles from '../../src/client/execution-components.module.css' -import { ExecutionSessions, ExecutionContinuation } from '../../src/client/execution-components.tsx' +import { ExecutionSessions, ExecutionContinuation, HistoricalContextLinks } from '../../src/client/execution-components.tsx' import type { ExecutionGraph } from '../../src/work-execution/observation.ts' import type { WorkExecution } from '../../src/work-types.ts' @@ -61,3 +61,12 @@ it('renders independent child entry, real job owner and distinct delivery/consum await writeFile(resolve(directory, 'component-contract.html'), `#138 公开组件合同

#138 公开组件合同验证

${html}${continuation}
`) } }) + + +it('uses the existing task action tokens for historical context buttons outside execution surfaces', () => { + const html = renderToStaticMarkup(createElement(HistoricalContextLinks, { sessions: [{ runtimeId: 'runtime', sessionId: 'session-existing-context' }], onEnter: () => {} })) + expect(html).toContain(styles.taskActions) + expect(html).toContain(styles.row) + expect(html).toContain('后续上下文') + expect(html).not.toContain('session-existing-context') +}) diff --git a/tests/client/execution-continuation-render.spec.ts b/tests/client/execution-continuation-render.spec.ts index e1ff7547..3f78ad60 100644 --- a/tests/client/execution-continuation-render.spec.ts +++ b/tests/client/execution-continuation-render.spec.ts @@ -191,3 +191,66 @@ it('keeps ended queue work disabled after its control has applied', async () => expect(view.nodes('button').some(node => node.props.children === '恢复处理')).toBe(false) expect(controller.client.command).not.toHaveBeenCalled() }) + +it.each(['native', 'header', 'task'] as const)('opens the exact saved historical context from the completed %s surface without executing work', async surface => { + const detail = taskDetail(), successor = { runtimeId: 'runtime', sessionId: 'session-existing-historical-context' } + detail.task!.completion = completion + detail.task!.sessions = [{ session: successor, purpose: 'related', historicalSource: session }] + const controller = controllerFor(detail) + const query = controller.client.query.bind(controller.client) + controller.client.query = async (value, signal) => value.type === 'session.work' + ? { status: 'completed', work: [{ ref, title: detail.task!.title, projectName: 'Project', purpose: 'control', historicalContexts: [successor] }] } + : query(value, signal) + const entered = vi.spyOn(controller, 'openSession') + let view: ReturnType + if (surface === 'task') { + await controller.openTask(ref) + view = mountProductionComponent({ controller }, 'CurrentExecution') + } else view = mountProductionComponent({ controller, sessionId: 'current', blank: false, placement: surface === 'header' ? 'header' : 'dock' }, surface === 'native' ? 'ExecutionConversationContinuation' : 'ExecutionSessionWorkEntry', new URL('../../src/client/execution-native.tsx', import.meta.url)) + mounted.push(view) + await expect.poll(() => view.text()).toContain('后续上下文') + expect(view.text()).not.toContain('后续上下文 ·') + expect(view.text()).not.toContain(successor.sessionId) + expect(entered).not.toHaveBeenCalled() + // Work detail also keeps its original execution Session link. The context is explicitly separate. + const context = view.nodes('button').find(button => button.props.children === '进入会话')! + await context.props.onClick() + await expect.poll(() => entered.mock.calls.length).toBe(1) + expect(entered).toHaveBeenCalledExactlyOnceWith(successor) + expect(controller.client.command).not.toHaveBeenCalled() + expect(detail.execution!.session).toEqual(session) + expect(detail.task!.completion).toEqual(completion) +}) + +it('rejects a stale historical context link rather than substituting the canonical executor', async () => { + const detail = taskDetail(), controller = controllerFor(detail) + detail.task!.completion = completion + const entered = vi.spyOn(controller, 'openSession') + await expect(controller.openHistoricalContext(ref, { runtimeId: 'runtime', sessionId: 'unregistered' })).rejects.toThrow('后续上下文暂不可进入') + expect(entered).not.toHaveBeenCalled() + expect(controller.client.command).not.toHaveBeenCalled() +}) + + +it.each(['control','history'] as const)('keeps the %s dock free of historical-context duplicates and preserves formal history navigation', async purpose => { + const detail = taskDetail(), successor = { runtimeId: 'runtime', sessionId: 'session-historical-context' } + detail.task!.completion = completion + detail.task!.sessions = [{ session: successor, purpose: 'related', historicalSource: session }] + const controller = controllerFor(detail, purpose), query = controller.client.query.bind(controller.client) + controller.client.query = vi.fn(async (value, signal) => value.type === 'session.work' + ? { status: 'completed' as const, work: [{ ref, title: detail.task!.title, projectName: 'Project', purpose, historicalContexts: [successor] }] } + : query(value, signal)) + const entered = vi.spyOn(controller, 'openSession') + const view = mountProductionComponent({ controller, sessionId: 'current', blank: false, placement: 'dock' }, 'ExecutionSessionWorkEntry', new URL('../../src/client/execution-native.tsx', import.meta.url)) + mounted.push(view) + await expect.poll(() => vi.mocked(controller.client.query).mock.calls.some(([value]) => value.type === 'session.work')).toBe(true) + expect(view.text()).not.toContain('后续上下文') + if (purpose === 'control') expect(view.text()).toBe('') + else { + expect(view.text()).toContain('处理会话已切换') + view.click('进入会话') + await expect.poll(() => entered.mock.calls.length).toBe(1) + expect(entered).toHaveBeenCalledExactlyOnceWith(session) + } + expect(controller.client.command).not.toHaveBeenCalled() +}) diff --git a/tests/work-completion.spec.ts b/tests/work-completion.spec.ts index 76a995a9..921dbf11 100644 --- a/tests/work-completion.spec.ts +++ b/tests/work-completion.spec.ts @@ -2,9 +2,10 @@ import { afterEach, expect, it } from 'vitest' import { mkdtemp, realpath, readFile, writeFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, dirname, basename } from 'node:path' -import { WorkApplication, type WorkHost } from '../src/work.ts' +import { WorkApplication, WorkDao, type WorkHost } from '../src/work.ts' import type { WorkCompleted, WorkResult, WorkTask, WorkExecution } from '../src/work-types.ts' import type { GitHubIssue, GitHubSourcePort } from '../src/work-sources/types.ts' +import { reserveContinuation } from '../src/work-execution/continuation.ts' const operator={operator:true} const roots:string[]=[] afterEach(async()=>{for(const root of roots.splice(0)){if(dirname(root)!==tmpdir()||!basename(root).startsWith('completion-contract-')||await realpath(root)!==root)throw Error('Unsafe cleanup');await rm(root,{recursive:true,force:true})}}) @@ -198,3 +199,70 @@ it.each(['missing', 'throws'] as const)('names %s result observation before acce expect(await h.guard(h.execution.session.sessionId)).toBe(true) expect(h.writes()).toBe(0) }) + +it.each([false,true])('durably links completed source to its exact existing historical context (already associated=%s) without changing completion or execution', async alreadyAssociated => { + const h=await setup(),task=await h.create('historical'),execution=await h.start(task) + const successor={runtimeId:'runtime',sessionId:'existing-context'} + h.host.sessions=async()=>[{ref:successor,resource:execution.resource,title:'Existing context'}] + const beforeDone=await h.detail(task) + const command={type:'task.associate-session',commandId:'historical-link',ref:execution.ref,expectedVersion:beforeDone.task!.version,session:successor,purpose:'related',historicalSource:execution.session} + expect(await h.app.execute({...command,commandId:'active-link'},operator)).toMatchObject({status:'failed',error:{code:'invalid-historical-context'}}) + if(alreadyAssociated) ok(await h.app.execute({type:'task.associate-session',commandId:'plain-link',ref:execution.ref,expectedVersion:beforeDone.task!.version,session:successor,purpose:'related'},operator)) + await h.done(execution) + const before=await h.detail(task) + const completedCommand={...command,expectedVersion:before.task!.version} + expect(await h.app.execute({...completedCommand,commandId:'wrong-source',historicalSource:{...execution.session,sessionId:'unrelated'}},operator)).toMatchObject({status:'failed',error:{code:'invalid-historical-context'}}) + expect(await h.app.execute({...completedCommand,commandId:'self-link',session:execution.session},operator)).toMatchObject({status:'failed'}) + h.host.sessions=async()=>[{ref:successor,resource:{...execution.resource,workspaceId:'other'},title:'Wrong workspace'}] + expect(await h.app.execute({...completedCommand,commandId:'wrong-workspace'},operator)).toMatchObject({status:'failed',error:{code:'incompatible-session'}}) + h.host.sessions=async()=>[{ref:successor,resource:execution.resource,title:'Existing context'}] + const linked=ok(await h.app.execute(completedCommand,operator)) + expect(linked.task!.sessions).toContainEqual({session:successor,purpose:'related',historicalSource:execution.session}) + expect(await h.app.execute({...completedCommand,commandId:'same-link',expectedVersion:linked.task!.version},operator)).toMatchObject({status:'completed',task:{version:linked.task!.version}}) + const {historicalSource:omitted,...legacyCommand}=completedCommand + expect(await h.app.execute({...legacyCommand,commandId:'legacy-repeat',expectedVersion:linked.task!.version},operator)).toMatchObject({status:'completed',task:{version:linked.task!.version}}) + const restored=new WorkApplication(h.app.directory,h.host) + const after=ok(await restored.query({type:'task.detail',ref:execution.ref},operator)) + expect(after.task!.completion).toEqual(before.task!.completion) + expect(after.task!.specification).toEqual(before.task!.specification) + expect(after.execution).toEqual(before.execution) + const source=ok(await restored.query({type:'session.work',session:execution.session},operator)) + expect(source.work).toMatchObject([{purpose:'control',historicalContexts:[successor]}]) + expect(await restored.query({type:'session.work',session:successor},operator)).toMatchObject({work:[{purpose:'related'}]}) + const {createElement}=await import('react'),{renderToStaticMarkup}=await import('react-dom/server') + const panelPath='../src/client/work-panel.tsx' + const {SessionWorkLinks}=await import(panelPath) + const markup=renderToStaticMarkup(createElement(SessionWorkLinks,{work:source.work!,onReveal:()=>{},onEnter:()=>{}})) + expect(markup).toContain('后续上下文') + expect(markup).toContain('进入会话') + expect(h.calls.ensure).toBe(1) +}) + + +it.each(['current','retired','reserved'] as const)('rejects another Task execution %s Session as historical context while preserving ordinary related association', async occupied => { + const h=await setup(),task=await h.create('completed-source'),execution=await h.start(task) + await h.done(execution) + const otherTask=await h.create('other-business'),otherExecution=await h.start(otherTask) + let target=otherExecution.session + await WorkDao.forDirectory(h.app.directory).transaction(async state=>{ + const other=state.executions.find(item=>item.id===otherExecution.id)! + if(occupied==='retired') { + other.epoch=2 + target={runtimeId:other.session.runtimeId,sessionId:'other-retired-session'} + other.retired=[{session:target,epoch:1,operationId:'a3a35145-1234-4567-8910-123456789012'}] + } + if(occupied==='reserved') { + reserveContinuation(other,state.tasks.find(item=>item.id===otherTask.id)!,'other-reservation',false) + target=other.continuation!.successor + } + }) + h.host.sessions=async()=>[{ref:target,resource:execution.resource,title:'Other business context'}] + const before=await h.detail(task),otherBefore=await h.detail(otherTask) + const command={type:'task.associate-session',commandId:'occupied-historical',ref:execution.ref,expectedVersion:before.task!.version,session:target,purpose:'related',historicalSource:execution.session} + expect(await h.app.execute(command,operator)).toMatchObject({status:'failed',error:{code:'session-in-use'}}) + expect((await h.detail(task)).task).toEqual(before.task) + expect((await h.detail(task)).execution).toEqual(before.execution) + expect((await h.detail(otherTask)).execution).toEqual(otherBefore.execution) + const {historicalSource:omitted,...ordinary}=command + expect(await h.app.execute({...ordinary,commandId:'ordinary-related'},operator)).toMatchObject({status:'completed',task:{sessions:[{session:target,purpose:'related'}]}}) +}) diff --git a/tests/work-execution-boundary.spec.ts b/tests/work-execution-boundary.spec.ts index cf04008e..f441320b 100644 --- a/tests/work-execution-boundary.spec.ts +++ b/tests/work-execution-boundary.spec.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, expect, it } from 'vitest' import LocalJobs from '@deepseek-ai/dsh-jobs-local' import { SessionId } from '@deepseek-ai/dsh-session' +import { defineTool } from '@deepseek-ai/dsh-tools' +import { buildExecutionGraph } from '../src/work-execution/observation.ts' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { ExecutionFixtures } from './work-execution-fixture.ts' import { DshWorkExecutionHost } from '../src/work-execution/dsh-host.ts' @@ -91,3 +93,47 @@ it('reads real retired-owner jobs without collecting results or including unowne expect(h.ctx.jobs.read(terminal, SessionId(old.sessionId)).result).toBe('untouched result') expect(h.model.requests).toHaveLength(0) }) + + +it('keeps running historical context and its jobs outside execution settlement while ordinary related sessions remain observed', async () => { + const h = await fixtures.mount(await fixtures.root()) + await h.ctx.plugin(LocalJobs) + h.ctx.jobs.attachController('historical-context-test') + const current = await h.ctx.sessionController.create({ workspaceId: h.workspace.id, agentPreset: 'contract' }) + const context = await h.ctx.sessionController.create({ workspaceId: h.workspace.id, agentPreset: 'contract' }) + const agent = h.ctx.agents.get(context.sessionId)! + let release!: () => void, entered!: () => void + const gate = new Promise(resolve => { release = resolve }) + const started = new Promise(resolve => { entered = resolve }) + h.ctx.tools.register(defineTool({ name: 'hold_historical_context', description: 'Independent historical conversation', parameters: {}, + output: { schema: { type: 'object', additionalProperties: false, properties: { result: { type: 'string', required: true } } }, render: (_, value) => [{ type: 'text', text: value.result }] }, + async execute() { entered(); await gate; return { result: 'Context complete' } } })) + const job = h.ctx.jobs.start({ kind: 'bash', label: 'historical context work', owner: context.sessionId, run: () => ({ cancel() { release() }, done: gate.then(() => ({ status: 'completed' as const })) }) }) + h.model.nextTool = { name: 'hold_historical_context', arguments: {} } + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Continue the independent conversation' }], source: { kind: 'user' } })) + await started + const execution: WorkExecution = { ...executionDefaults, id: 'execution', ref: { projectId: 'project', taskId: 'task' }, session: { runtimeId: 'runtime', sessionId: current.sessionId }, resource: { runtimeId: 'runtime', workspaceId: h.workspace.id } } + const historical = { session: { runtimeId: 'runtime', sessionId: context.sessionId }, purpose: 'related' as const, historicalSource: execution.session } + const host = new DshWorkExecutionHost(h.ctx, 'runtime') + host.connectObservation(async () => [historical]) + try { + const facts = await host.observation(execution) + expect(facts.settlement).toBe('settled') + expect(facts.blockers).toEqual([]) + expect(facts.sessions.map(row => row.session.sessionId)).toEqual([current.sessionId]) + const { historicalSource: omitted, ...related } = historical + const ordinary = await host.observation(execution, [related]) + expect(ordinary.sessions.find(row => row.session.sessionId === context.sessionId)).toMatchObject({ status: 'running', jobs: [expect.objectContaining({ id: job })] }) + expect(ordinary.blockers).toContainEqual({ code: 'executor-unsettled', session: historical.session }) + expect(ordinary.blockers.some(row => row.jobId === job)).toBe(true) + const task = { id: 'task', projectId: 'project', title: 'Completed source', goal: 'Goal', version: 1, agentId: 'agent', specification: { version: 1, text: 'Spec', acceptance: [] }, specificationHistory: [], breakdown: [], children: [], sessions: [historical], activity: [] } + const graph = buildExecutionGraph({ task, execution, observation: ordinary }) + expect(graph.sessions.map(row => row.session.sessionId)).toEqual([current.sessionId]) + expect(graph.blockers).toEqual([]) + expect(buildExecutionGraph({ task: { ...task, sessions: [related] }, execution, observation: ordinary }).sessions.some(row => row.session.sessionId === context.sessionId)).toBe(true) + } finally { + release() + await fixtures.run(() => agent.whenIdle()) + await h.ctx.jobs.wait(job, 1000, context.sessionId) + } +})