From bf53f011c96e3699f8d3e7d400e411041635d18e Mon Sep 17 00:00:00 2001 From: aiagent Date: Mon, 19 Oct 2026 21:58:17 -0700 Subject: [PATCH] Visualize version deliveries and task dependency branches --- README.md | 7 +- SKILL.md | 8 ++ agent_control/store.py | 21 +++- assets/dashboard/src/lib/types.ts | 1 + assets/dashboard/src/pages/task-graph.ts | 50 ++++++++ assets/dashboard/src/pages/task-relations.tsx | 44 +++++-- assets/dashboard/tests/task-graph.test.mjs | 23 ++++ references/design-system.md | 12 +- references/operations.md | 10 ++ tests/test_delivery_graph.py | 114 ++++++++++++++++++ 10 files changed, 275 insertions(+), 15 deletions(-) create mode 100644 assets/dashboard/src/pages/task-graph.ts create mode 100644 assets/dashboard/tests/task-graph.test.mjs create mode 100644 tests/test_delivery_graph.py diff --git a/README.md b/README.md index 0d906bd..0ad73d8 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ 看板分为 **总览、任务、集群、沟通、决策、日志** 六页。前端基于随仓库维护的 React、TypeScript、Vite、Tailwind 和官方 shadcn/ui 模板,固定配色与组件规范,AI 在已有模板上组合功能。 +任务分支视图以显式版本交付为主线:默认显示未结束任务和必要前置,可切换全部历史;悬停、聚焦或点选突出上下游。仅实际集成验收通过的关系绘制汇合,无归属和缺时间记录明确标示,不冒充 Git 提交历史。 + 协调服务使用 Python 标准库和本地 SQLite,保存任务、消息、事件与会话映射。AI 可以留在已登录的本机运行;服务与监控部署在获准集群,不需要向集群复制模型凭据。沟通页绑定真实会话,消息排队和历史镜像使用受支持的宿主接口。 ## 准备和启动 @@ -16,8 +18,11 @@ npm ci npm run build cd ../.. python3 -m unittest discover -s tests -v +node --test assets/dashboard/tests/task-graph.test.mjs ``` +图逻辑测试直接读取 TypeScript 模块,需具备类型剥离能力的 Node;本项目验证环境为 Node 24。 + 在获准 Linux 服务账号下初始化任务自有运行目录(默认示例仅监听本机): ```sh @@ -59,7 +64,7 @@ python3 scripts/bridge_service.py --config /path/to/private/bridge.json 本仓库不包含任何实际集群地址、客户案例、私有业务代码或聊天历史。公开仓库不会自动公开你的局域网运行实例。前端上游授权与组件来源见模板内的第三方说明。 -执行负责人先处理归属事项并可靠保存报告,跨任务或阻塞交唯一直属副主控定向核查;只有人类决策、冲突和重大事件升级总控。人类需求直接交总控。紧急事件遇副主控失联可按约定有界越级,沿用关联ID去重,不广播或循环转发。 +执行负责人先处理归属事项并可靠保存报告,授权范围内可与相关执行者点对点协调并保存关键交接,跨任务归属冲突、范围改变或无法解决的阻塞交唯一直属副主控定向核查;只有人类决策、冲突和重大事件升级总控。人类需求直接交总控。紧急事件遇副主控失联可按约定有界越级,沿用关联ID去重,不广播或循环转发。 完成报告先进入私有持久outbox,有界重试并幂等确认;执行完成与集成验收分别记录。通知要求每目标最多一条未处理,其余保留并合并。当前支持execution_notification_targets显式映射:依据正式任务session_id,将任务相关事件和ETA优先交执行者;未映射事项及系统事件交配置的唯一副主控,决策直达总控。目标变更须保留旧回执并对账迁移,不盲重放积压。宿主原生队列总数与取消能力未知时如实说明,不能声称已清空。配置、实现范围与验收要求见[运行接入](references/operations.md#一层副主控的路由与可靠上报)。 diff --git a/SKILL.md b/SKILL.md index 2f95982..bd93f17 100644 --- a/SKILL.md +++ b/SKILL.md @@ -39,6 +39,8 @@ description: 组织项目的多个 AI 会话并行工作,将进度、证据和 归属明确的事项由执行 agent 先在授权内处理并可靠落库。跨任务依赖、阻塞或无法本层解决的问题定向交唯一直属副主控;副主控读取关联任务、原会话和证据,向相关负责人定向核查,协调结果同样落库。仅人类决策、协调冲突或重大事件升级总控;普通进度、短暂重连、自动恢复不注入总控或人类入口。人类提出的新需求直接到总控,由总控分解,不排在底层事件队列之后。 +执行者在已明确的共享接口、文件归属及授权范围内可点对点协商,不必把所有信息逐级汇报。协商结果由唯一事项负责人保存结构化决定或交接记录并更新任务;涉及归属冲突、范围改变或本层无法解决的跨任务阻塞再交副主控。副主控可自行处理既有权限内的协调,只有重大风险、跨范围决策或人类待决策升级总控。禁止群发、无变化互相唤醒或以协调为由扩大权限。 + 报告保留项目/任务ID、事件或报告关联ID、版本、唯一负责人、接收方及处理回执。同一事项按关联ID去重、合并增量,回复关联原事项,不广播、不循环转发,也不另建同义事项绕过队列。重大紧急事件遇副主控失联时,先按任务约定的时限和重试上限核查;确需及时行动才携证据、影响及已尝试联系记录有界越级总控,同一关联ID最多一次,恢复后补记处理归属。越级不增加操作权限。 当前 notifier 已提供执行者优先的显式映射:通过正式 `/api/state` 查询任务的 `session_id`,在 `execution_notification_targets` 中找到获准执行thread后,任务相关hook/ETA先投该唯一执行者。未配置执行者或系统事件回退内部核查目标;本流程将 `internal_event_thread_id` 与 `eta_review_thread_id` 配为唯一直属副主控。决策仍直达总控。映射须显式配置,不根据名称猜测、不自动创建会话;配置和回执迁移约束见运行指南。 @@ -149,3 +151,9 @@ description: 组织项目的多个 AI 会话并行工作,将进度、证据和 运维日志与沟通分离:历史及后续自动故障/恢复整轮消息不进入聊天;保留用户主动询问时的解释。日志按故障聚合,信息/警告/错误/严重与处理中/已恢复分别用颜色和文字表示,支持筛选和详情。原始审计不删;安全投影遮蔽令牌、私有路径与工具/推理载荷。桥接首次失败为警告,持续功能失效达到私有配置的升级窗口(默认120秒)才升级严重并通知人类,已升级故障恢复最多通知一次;重大简报链接具体日志卡。 人类入口的每次输入保持轻量,不重复注入整个项目JSON快照;需要进度事实时按需读取控制API与执行会话。网页投影隐藏内部封装不等于可以向原会话无界灌入状态,后台指令与证据仍要有界。 + +## 版本交付关系图 + +任务分支视图以版本交付为主线、独立工作任务为支线,节点只表达实际登记的关键里程碑,不冒充Git提交或提交历史。交付任务显式使用kind=delivery,工作任务使用kind=work及delivery_id归属;没有归属显示未归属,不从version中的提交哈希推测。只有真实集成关系且源、目标均通过集成验收才绘制汇合。 + +默认显示所有未结束任务及递归必要前置,显示全部开关用于历史。悬停、键盘焦点及触屏选择突出递归上下游关系;无关项淡化但可读,退出交互恢复,不修改任务事实或自动开启全部。字段校验和迁移见references/operations.md,交互与验收见references/design-system.md。 diff --git a/agent_control/store.py b/agent_control/store.py index 33cb52a..6565d35 100644 --- a/agent_control/store.py +++ b/agent_control/store.py @@ -43,6 +43,7 @@ TASK_REPORT_DEFAULTS = {'execution_status':'pending','last_report_at':None,'last TASK_RELATION_DEFAULTS = { + 'kind':'work', 'delivery_id':None, 'goal':'', 'scope':'', 'coupling_assessment':'', 'owner':'', 'integration_owner':'', 'version':'', 'parent_id':None, 'merged_into_id':None, 'depends_on':[], 'parallel_with':[], 'merge_sources':[], 'parallel_scope':'', 'acceptance_criteria':[], 'sync_points':[], 'integration_status':'not_started'} @@ -327,7 +328,7 @@ class Store: def _task_defaults(record): return {**{k:list(v) if isinstance(v,list) else v for k,v in TASK_RELATION_DEFAULTS.items()}, **ETA_DEFAULTS, **TASK_REPORT_DEFAULTS, 'report_evidence':[], - 'current_progress':'', 'progress_updated':None, **record} + 'current_progress':'', 'progress_updated':None, 'created_at':None, **record} @staticmethod @@ -350,7 +351,11 @@ class Store: if not isinstance(record[field],str): raise Invalid('invalid '+field) string(record[field],field,limit,True) - for field in ('parent_id','merged_into_id'): + if record['kind'] not in ('work','delivery'): + raise Invalid('invalid task kind') + if record['kind']=='delivery' and record['delivery_id'] is not None: + raise Invalid('delivery cannot belong to another delivery') + for field in ('parent_id','merged_into_id','delivery_id'): value = record[field] if value is not None: identifier(value,field) @@ -385,6 +390,13 @@ class Store: @staticmethod def _validate_task_graphs(tasks): + for task_id,task in tasks.items(): + delivery = task.get('delivery_id') + if delivery is not None: + if delivery==task_id or delivery not in tasks: + raise Invalid('invalid delivery reference') + if task.get('kind','work')=='delivery' or tasks[delivery].get('kind','work')!='delivery': + raise Conflict('delivery reference requires a delivery task') graphs = [ {key:([value['parent_id']] if value.get('parent_id') else []) for key,value in tasks.items()}, {key:value.get('depends_on',[]) for key,value in tasks.items()}, @@ -567,7 +579,7 @@ class Store: def task(self, data, key=None): if key is not None and 'id' in data and data['id'] != key: raise Invalid('task id differs from target') - if 'progress_updated' in data or (ETA_MANAGED | set(TASK_REPORT_DEFAULTS)) & set(data): + if 'created_at' in data or 'progress_updated' in data or (ETA_MANAGED | set(TASK_REPORT_DEFAULTS)) & set(data): raise Invalid('server managed task field') key = identifier(key or data.get('id') or secrets.token_hex(12)) rejected = False @@ -578,7 +590,7 @@ class Store: record = dict(old, **data) record['id'] = key allowed = {'id','name','session_id','status','phase','eta_min_minutes','eta_max_minutes', - 'eta_basis','decision','updated','current_progress','progress_updated'} | set(ETA_DEFAULTS) | set(TASK_RELATION_DEFAULTS) | set(TASK_REPORT_DEFAULTS) + 'eta_basis','decision','updated','current_progress','progress_updated','created_at'} | set(ETA_DEFAULTS) | set(TASK_RELATION_DEFAULTS) | set(TASK_REPORT_DEFAULTS) if set(record) - allowed: raise Invalid('unknown task field') record['name'] = string(record.get('name'), 'name', 200) @@ -600,6 +612,7 @@ class Store: record['current_progress'] = string(progress,'current_progress',1000,True) record, related = self._stage_task_relations(db,record,old,set(data)) now = time.time() + record['created_at'] = old.get('created_at') if row else now rejected = self._eta_change(record,old,data,now) if rejected: record = self._task_defaults(old) diff --git a/assets/dashboard/src/lib/types.ts b/assets/dashboard/src/lib/types.ts index a4c1967..1c25929 100644 --- a/assets/dashboard/src/lib/types.ts +++ b/assets/dashboard/src/lib/types.ts @@ -1,4 +1,5 @@ export type Task = { + kind?: 'work' | 'delivery'; delivery_id?: string | null; created_at?: number | null; session_id?: string; execution_status?: 'pending' | 'running' | 'completed' | 'blocked' | 'failed'; last_report_at?: number | null; last_report_id?: string | null goal?: string; scope?: string; coupling_assessment?: string; owner?: string; integration_owner?: string; version?: string diff --git a/assets/dashboard/src/pages/task-graph.ts b/assets/dashboard/src/pages/task-graph.ts new file mode 100644 index 0000000..f86a912 --- /dev/null +++ b/assets/dashboard/src/pages/task-graph.ts @@ -0,0 +1,50 @@ +import type { Task } from '../lib/types.ts' +export type GraphEdge = { from: string; to: string; kind: 'dependency' | 'membership' | 'merge' } +export function walk(seeds: Iterable, adjacency: Map) { + const seen = new Set(), pending = [...seeds] + while (pending.length) { const id = pending.pop()!; if (seen.has(id)) continue; seen.add(id); for (const next of adjacency.get(id) ?? []) if (!seen.has(next)) pending.push(next) } + return seen +} +export function buildGraph(tasks: Task[], all = false) { + const byId = new Map(tasks.map(task => [task.id, task])) + const parents = new Map(tasks.map(task => [task.id, (task.depends_on ?? []).filter(id => byId.has(id))])) + const visible = all ? new Set(byId.keys()) : walk(tasks.filter(task => !['completed', 'cancelled', 'failed'].includes(task.status)).map(task => task.id), parents) + for (const id of [...visible]) { const delivery = byId.get(byId.get(id)?.delivery_id ?? ''); if (delivery?.kind === 'delivery') visible.add(delivery.id) } + const nodes = tasks.filter(task => visible.has(task.id)) + const edges: GraphEdge[] = [], unique = new Set() + function add(from: string, to: string, kind: GraphEdge['kind']) { const key = JSON.stringify([from, to, kind]); if (visible.has(from) && visible.has(to) && !unique.has(key)) { unique.add(key); edges.push({ from, to, kind }) } } + const mergeTargets = new Map() + for (const target of tasks) for (const source of target.merge_sources ?? []) mergeTargets.set(source, [...(mergeTargets.get(source) ?? []), target.id]) + for (const task of nodes) { + for (const id of task.depends_on ?? []) add(id, task.id, 'dependency') + if (task.kind !== 'delivery' && byId.get(task.delivery_id ?? '')?.kind === 'delivery') add(task.delivery_id!, task.id, 'membership') + const targets = [...(task.merged_into_id ? [task.merged_into_id] : []), ...(mergeTargets.get(task.id) ?? [])] + for (const targetId of targets) { const target = byId.get(targetId); if (target?.kind === 'delivery' && target.integration_status === 'verified' && task.integration_status === 'verified') add(task.id, target.id, 'merge') } + } + return { nodes, edges, deliveries: nodes.filter(task => task.kind === 'delivery').sort((a, b) => (validCreated(a) ?? Number.MAX_SAFE_INTEGER) - (validCreated(b) ?? Number.MAX_SAFE_INTEGER) || a.id.localeCompare(b.id)) } +} +export function related(id: string, edges: GraphEdge[]) { + const forward = new Map(), backward = new Map() + for (const edge of edges) { if (edge.kind === 'membership') continue; forward.set(edge.from, [...(forward.get(edge.from) ?? []), edge.to]); backward.set(edge.to, [...(backward.get(edge.to) ?? []), edge.from]) } + return new Set([...walk([id], forward), ...walk([id], backward)]) +} + +export function validCreated(task: Task) { return typeof task.created_at === 'number' && Number.isFinite(task.created_at) && task.created_at > 0 ? task.created_at : null } + +export function compactLayout(graph: ReturnType) { + const rows: { task: Task; y: number; group: string | null }[] = [] + const positions = new Map() + const starts = new Map() + const groups = new Map() + const deliveryIds = new Set(graph.deliveries.map(task => task.id)) + const independent: Task[] = [] + for (const task of graph.nodes) { + if (task.kind === 'delivery') continue + if (task.delivery_id && deliveryIds.has(task.delivery_id)) { const items = groups.get(task.delivery_id) ?? []; items.push(task); groups.set(task.delivery_id, items) } + else independent.push(task) + } + const add = (task: Task, group: string | null) => { const y = 50 + rows.length * 100; rows.push({ task, y, group }); positions.set(task.id, { x: task.kind === 'delivery' ? 32 : 100, y }) } + for (const delivery of graph.deliveries) { starts.set(delivery.id, 15 + rows.length * 100); for (const task of groups.get(delivery.id) ?? []) add(task, delivery.id); add(delivery, delivery.id) } + for (const task of independent) add(task, null) + return { rows, positions, starts, height: Math.max(100, rows.length * 100) } +} diff --git a/assets/dashboard/src/pages/task-relations.tsx b/assets/dashboard/src/pages/task-relations.tsx index 7700875..4363bd6 100644 --- a/assets/dashboard/src/pages/task-relations.tsx +++ b/assets/dashboard/src/pages/task-relations.tsx @@ -1,17 +1,43 @@ +import { useId, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' -import { Panel, Empty, Status } from '@/components/shared' +import { Empty, Status } from '@/components/shared' import type { Task } from '@/lib/types' +import { phaseLabels } from '@/lib/types' +import { buildGraph, related, validCreated, compactLayout } from './task-graph' export function TaskLinks({ ids, tasks, onOpen }: { ids: string[]; tasks: Task[]; onOpen: (id: string) => void }) { return
{[...new Set(ids)].map(id => )}
} export default function TaskRelations({ tasks, onOpen }: { tasks: Task[]; onOpen: (id: string) => void }) { - const groups = [ - { title: '父子拆分', description: '父任务包含子任务,不表示先后执行顺序。', label: '包含', edges: tasks.flatMap(task => task.parent_id ? [[task.parent_id, task.id]] : []) }, - { title: '前置依赖', description: '箭头从前置任务指向依赖它的任务;具体等待点见详情。', label: '前置于', edges: tasks.flatMap(task => (task.depends_on ?? []).map(id => [id, task.id])) }, - { title: '归并', description: '箭头从来源任务指向接收归并的任务,不代表已经完成集成。', label: '归并到', edges: tasks.flatMap(task => [...(task.merged_into_id ? [[task.id, task.merged_into_id]] : []), ...(task.merge_sources ?? []).map(id => [id, task.id])]) }, - ] - return

这是任务关系图,不是 Git 分支。不同关系分别展示;可并行范围与同步点在任务详情中查看。

-
{tasks.map(task =>
{Boolean(task.parallel_with?.length) &&

有可并行任务,查看详情确认范围

}
)}
- {groups.map(group => {group.edges.length ?
    {[...new Map(group.edges.map(edge => [JSON.stringify(edge), edge])).values()].map(([from, to]) =>
  • {group.label} →
  • )}
: 尚未登记此类关系。}
)} + const [all, setAll] = useState(false), [hover, setHover] = useState(null), [focus, setFocus] = useState(null), [picked, setPicked] = useState(null) + const graph = useMemo(() => buildGraph(tasks, all), [tasks, all]) + const active = hover ?? focus ?? picked + const highlighted = useMemo(() => active ? related(active, graph.edges) : null, [active, graph.edges]) + const marker = useId().replace(/:/g, '') + const layout = useMemo(() => compactLayout(graph), [graph]) + const labels = new Map(graph.nodes.map(task => [task.id, task.name])) + const dim = (id: string) => highlighted && !highlighted.has(id) ? 'opacity-50' : '' + return
+
+

交付按登记时间分组,交付节点位于组末。主线排列不是因果或 Git 历史;默认显示未结束任务与递归前置。悬停或聚焦查看上下游,点击名称或圆节点打开详情。

+
│ 交付主线细线:明确归属粗线箭头:已验证汇合虚线箭头:前置依赖○ 登记同步点
+ {!graph.nodes.length ? 当前没有未结束任务或前置任务。可选择显示全部查看历史。 :
setHover(null)}> +
+ + {layout.rows.map(({task, y, group}) =>
setHover(task.id)} onMouseLeave={() => setHover(null)} onFocusCapture={() => setFocus(task.id)} onBlurCapture={event => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocus(null) }}> + +

{task.kind === 'delivery' ? '交付' : group ? `归属 ${labels.get(group)}` : '无明确交付归属'} · {task.id}{task.version ? ` · ${task.version}` : ''}{task.kind === 'delivery' && !validCreated(task) ? ' · 时间未登记' : ''}

+ {!!task.sync_points?.length &&
{task.sync_points.map(point => ○ {point.label} · {{waiting:'等待',ready:'已就绪',completed:'已完成'}[point.status]})}
} +
+

{task.integration_status === 'verified' ? '集成已验证' : phaseLabels[task.phase] || task.phase}

+ +
+ )} +
+
} } diff --git a/assets/dashboard/tests/task-graph.test.mjs b/assets/dashboard/tests/task-graph.test.mjs new file mode 100644 index 0000000..04e927e --- /dev/null +++ b/assets/dashboard/tests/task-graph.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildGraph, related, validCreated } from '../src/pages/task-graph.ts' +const task = (id, extra = {}) => ({ id, name:id, status:'running', phase:'implementation', ...extra }) +test('long chains and cycles use iterative traversal', () => { + const tasks=Array.from({length:15000},(_,i)=>task(String(i),{status:i===14999?'running':'completed',depends_on:i?[String(i-1)]:['14999']})) + const graph=buildGraph(tasks); assert.equal(graph.nodes.length,15000); assert.equal(related('7000',graph.edges).size,15000) +}) +test('multiple prerequisites included, unrelated history excluded',()=>{ + const g=buildGraph([task('a',{depends_on:['b','c']}),task('b',{status:'completed',depends_on:['d']}),task('c',{status:'completed'}),task('d',{status:'completed'}),task('old',{status:'completed'})]);assert.deepEqual(g.nodes.map(t=>t.id),['a','b','c','d']);assert.equal(g.edges.length,3) +}) +test('no dependencies and legacy version do not infer delivery',()=>{const g=buildGraph([task('d',{kind:'delivery',version:'v'}),task('w',{version:'v'})]);assert.equal(g.edges.length,0);assert.equal(related('w',g.edges).size,1)}) +test('all history toggle, explicit delivery context, missing IDs',()=>{const ts=[task('d',{kind:'delivery',status:'completed'}),task('w',{delivery_id:'d',depends_on:['missing']}),task('h',{status:'completed'})];assert.equal(buildGraph(ts).nodes.length,2);assert.equal(buildGraph(ts,true).nodes.length,3)}) +test('merge requires explicit relationship, delivery target and both verified',()=>{ + for(const source of ['integrated','verified'])for(const target of ['integrated','verified']){const g=buildGraph([task('w',{merged_into_id:'d',integration_status:source}),task('d',{kind:'delivery',integration_status:target})],true);assert.equal(g.edges.filter(e=>e.kind==='merge').length,source==='verified'&&target==='verified'?1:0)} + assert.equal(buildGraph([task('w',{integration_status:'verified'}),task('d',{integration_status:'verified',merge_sources:['w']})],true).edges.length,0) + assert.equal(buildGraph([task('w',{integration_status:'verified'}),task('d',{kind:'delivery',integration_status:'verified',merge_sources:['w']})],true).edges.length,1) +}) +test('delivery order uses valid created_at only',()=>{const g=buildGraph([task('z',{kind:'delivery',created_at:2}),task('a',{kind:'delivery',updated:1}),task('b',{kind:'delivery',created_at:1})]);assert.deepEqual(g.deliveries.map(t=>t.id),['b','z','a']);assert.equal(validCreated(task('x',{created_at:NaN})),null)}) +test('highlight ancestors and descendants without unrelated siblings',()=>{const g=buildGraph([task('a'),task('b',{depends_on:['a']}),task('c',{depends_on:['a']}),task('d',{depends_on:['b']})]);assert.deepEqual([...related('b',g.edges)].sort(),['a','b','d'])}) +test('compact layout places delivery at group end with distinct origin and 100px rows', async()=>{ + const {compactLayout}=await import('../src/pages/task-graph.ts');const g=buildGraph([task('d',{kind:'delivery',created_at:1}),task('w',{delivery_id:'d'}),task('solo')]);const l=compactLayout(g);assert.deepEqual(l.rows.map(r=>r.task.id),['w','d','solo']);assert.equal(l.positions.get('d').y-l.positions.get('w').y,100);assert.ok(l.starts.get('d')可定位,关闭详情只移除task。空/单任务/已完成/多依赖保持一致,过期状态与ETA规则不因切换消失。任务关系与Git分支不混用。 + +## 交付主线与任务支线交互 + +分支视图采用Git Graph形态表达任务交付关系,不表示真实Git提交。主线由kind=delivery任务按有效created_at的登记顺序组成,标签使用name/version;旧无时间项稳定按ID并标“时间未登记/顺序未登记”,不以updated代替,不暗示版本因果;任务按delivery_id分支,无归属单独展示。节点仅显示已有真实关键里程碑,不根据进度编造历史。实际merged_into_id或merge_sources关系只有在目标为delivery且源、目标integration_status均verified时显示汇合;真实depends_on用虚线,图例明确区分归属、依赖与验收汇合。 + +默认包含全部未结束状态(包括waiting、blocked和验证/集成阶段)及递归必要前置;显式“显示全部”显示历史。交付容器可为布局保留,但不应借容器显示无关历史工作任务。无版本或无归属明确显示未归属,不猜测版本。 + +悬停节点高亮递归上游、下游及对应边,无关节点与边淡化但文字仍可读;mouseleave恢复。键盘focus提供等效交互,触屏通过选中节点提供同效高亮和明确的详情按钮;退出选择恢复。高亮不改变事实、筛选开关或自动切换全部。详情仍保留任务定位链接、目标、负责人、依赖、验收与集成状态。 + +复用固定token和既有组件,以纯SVG与HTML实现,不引入新依赖。遍历使用visited与迭代队列/栈防循环和长链调用栈溢出。真实浏览器验收默认/全部、hover/focus/触屏、详情及窄屏;纯逻辑测试覆盖长链、多依赖、无依赖、筛选和汇合验收门禁。 diff --git a/references/operations.md b/references/operations.md index d78f086..af1d349 100644 --- a/references/operations.md +++ b/references/operations.md @@ -136,3 +136,13 @@ POST /api/tasks//integrate {source_ids,owner,status,version?,reason}事 每个目标最多一条未处理通知;应用侧启用 `enforce_backpressure`,并配置 `max_inflight_per_target: 1`(当前实现允许1至3,本协作规则要求1)。accepted/submitting/observed且未processing_done的投递仍占名额,只有真实处理证据才能释放;发送确认本身不释放。后续通知留在持久outbox,同一关联事项合并,未知项先对账。仅限制单次发送批量不等于每目标背压;未启用或未验收时不得宣称已满足。 queue_health分别报告application_pending、inflight_by_target及已知完成情况。当前host_full_queue_count为null、host_queue_cancel_supported为false,表示没有完整宿主原生队列总数和取消能力;不代表原生队列为空。停止本机notifier、清应用待办或取消旧ETA记录都不能证明已进入宿主的输入已撤回。只能用受支持宿主接口核实取消结果,无法核实时保留未知并禁止盲重发,不访问内部数据库或抢writer锁。 + +## 版本交付归属契约 + +Task.kind取work或delivery,旧数据缺省work;delivery_id缺省null。工作任务可显式引用已存在的kind=delivery任务,禁止自指、不存在引用及引用普通工作任务。delivery任务自身不能设置delivery_id;已被工作任务引用的delivery不得降为work。创建和更新均校验,执行报告不能绕过归属或集成字段的保护。旧数据不根据version推断归属;正式迁移由集成负责人显式登记并保留原关系与验收证据。 + +created_at由服务端在新建任务时记录,拒绝客户端输入及更新/报告改写;旧记录缺失返回null,不补造历史时间。交付主线仅按有效created_at展示版本登记顺序,以name/version标识;同时间稳定按ID排序。旧无时间项按ID稳定展示并标“时间未登记/顺序未登记”,不得用updated代替创建时间,也不得将登记顺序解释为版本因果或Git历史。delivery_id仅表示归属,不证明已经合入;绘制汇合必须有merged_into_id或merge_sources真实关系,目标kind=delivery且源、目标integration_status均为verified。completed本身不满足门禁。depends_on仍是前置关系,parent_id仍是包含关系,不能拿这些字段替代集成验收。 + +本轮验收包括旧数据默认、创建和更新非法归属、被引用交付降级、报告字段保护;前端纯逻辑覆盖长链、多依赖、无依赖、筛选与汇合门禁。执行负责人以既有持久报告流程上报真实证据,收到落库回执后等待集成;集成负责人另做真实浏览器验证和发布,不能以执行完成代替正式验收。 + +执行会话可按人类已授权的项目协作范围,使用宿主支持的定向消息就共享接口与交接点相互协调;无需每次抄送副主控。唯一事项负责人把关键决定、版本、影响与交接回执落库;范围或归属冲突才交直属副主控。普通进度只更新状态,不制造互相唤醒链。副主控的判断可由推理模型结合明确规则承担,具体模型、费用与执行权限分别核实;用户仅询问模型能力不构成切换型号或购买服务的授权。 diff --git a/tests/test_delivery_graph.py b/tests/test_delivery_graph.py new file mode 100644 index 0000000..a5b7d46 --- /dev/null +++ b/tests/test_delivery_graph.py @@ -0,0 +1,114 @@ +"""Explicit delivery ownership without inferred merge or historical timestamps.""" +import json +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +from agent_control.store import Store, Invalid + + +class DeliveryGraphTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.path = Path(self.tmp.name)/'delivery.sqlite3' + self.store = Store(self.path) + self.store.session(dict(id='worker',name='Worker')) + + def tearDown(self): + self.tmp.cleanup() + + def task(self, tid): + return next(t for t in self.store.state()['tasks'] if t['id']==tid) + + def create(self, tid, **fields): + return self.store.task(dict(id=tid,name=tid,session_id='worker',**fields)) + + def report(self, **fields): + return self.store.task_report(dict(report_id='r',task_id='w',session_id='worker', + stage='execution_completed',current_progress='Checks passed',summary='Execution finished',evidence=[],**fields)) + + def test_create_update_clear_and_persistence(self): + self.create('d',kind='delivery') + work=self.create('w',delivery_id='d') + self.assertEqual(work['kind'],'work') + self.assertEqual(work['delivery_id'],'d') + self.assertEqual(Store(self.path).state()['tasks'],self.store.state()['tasks']) + self.store.task(dict(delivery_id=None),'w') + self.assertIsNone(self.task('w')['delivery_id']) + self.store.task(dict(kind='delivery'),'w') + self.assertEqual(self.task('w')['kind'],'delivery') + + def test_invalid_references_and_kind_are_atomic(self): + self.create('d',kind='delivery') + self.create('w') + before=self.task('w') + for fields in [dict(kind='release'),dict(kind=None),dict(kind=[]),dict(delivery_id='missing'), + dict(delivery_id='w'),dict(delivery_id=True),dict(delivery_id=''), + dict(kind='delivery',delivery_id='d')]: + with self.subTest(fields=fields): + with self.assertRaises(Invalid): self.store.task(fields,'w') + self.create('other') + with self.assertRaises(Invalid): self.store.task(dict(delivery_id='other'),'w') + self.assertEqual(self.task('w'),before) + with self.assertRaises(Invalid): self.create('self',delivery_id='self') + self.assertFalse(any(t['id']=='self' for t in self.store.state()['tasks'])) + + def test_referenced_delivery_cannot_downgrade(self): + self.create('d',kind='delivery') + self.create('w',delivery_id='d') + with self.assertRaises(Invalid): self.store.task(dict(kind='work'),'d') + self.assertEqual(self.task('d')['kind'],'delivery') + self.store.task(dict(delivery_id=None),'w') + self.store.task(dict(kind='work'),'d') + self.assertEqual(self.task('d')['kind'],'work') + + def test_old_data_default_does_not_guess_from_version_or_merge(self): + with self.store.db() as db: + db.execute('INSERT INTO tasks(id,data) VALUES(?,?)',('old',json.dumps(dict(id='old',name='Old',version='abc123')))) + old=self.task('old') + self.assertEqual(old['kind'],'work') + self.assertIsNone(old['delivery_id']) + self.assertIsNone(old['created_at']) + self.store.task(dict(current_progress='Reviewed existing work'),'old') + self.assertIsNone(self.task('old')['created_at']) + self.assertEqual(self.task('old')['kind'],'work') + + def test_created_at_is_server_owned_stable_across_update_and_report(self): + with patch('agent_control.store.time.time',return_value=100): + self.create('d',kind='delivery') + self.create('w',delivery_id='d') + with patch('agent_control.store.time.time',return_value=200): + self.store.task(dict(name='Renamed'),'w') + self.report(version='candidate') + self.assertEqual(self.task('w')['created_at'],100) + self.assertEqual(self.task('d')['created_at'],100) + for fields in [dict(created_at=1),dict(created_at=None),dict(created=1)]: + with self.assertRaises(Invalid): self.store.task(fields,'w') + with self.assertRaises(Invalid): self.create('forged',created_at=0) + for fields in [dict(kind='delivery'),dict(delivery_id=None),dict(created_at=1)]: + with self.assertRaises(Invalid): self.report(**fields) + self.assertEqual(self.task('w')['kind'],'work') + self.assertEqual(self.task('w')['delivery_id'],'d') + + def test_assignment_and_completion_never_invent_merge_or_verification(self): + self.create('d',kind='delivery') + self.create('w',delivery_id='d',status='completed',phase='done') + self.assertIsNone(self.task('w')['merged_into_id']) + self.assertEqual(self.task('d')['merge_sources'],[]) + self.assertEqual(self.task('w')['integration_status'],'not_started') + self.store.integrate('d',dict(source_ids=['w'],owner='Integrator',status='verified',reason='Actual target acceptance')) + self.assertEqual(self.task('w')['merged_into_id'],'d') + self.assertEqual(self.task('d')['merge_sources'],['w']) + self.assertEqual(self.task('w')['integration_status'],'not_started') + self.assertEqual(self.task('d')['integration_status'],'verified') + self.store.task(dict(integration_status='verified'),'w') + self.assertEqual(self.task('w')['integration_status'],'verified') + + def test_existing_work_to_work_merge_remains_compatible(self): + self.create('d',kind='delivery') + self.create('w',delivery_id='d') + self.create('target') + self.store.integrate('target',dict(source_ids=['w'],owner='Integrator',status='waiting',reason='Existing relationship')) + self.assertEqual(self.task('w')['merged_into_id'],'target') + self.assertEqual(self.task('w')['delivery_id'],'d') + self.assertEqual(self.task('target')['kind'],'work')