Commit 3d9cb5bdb6b9f7a5328b24b2fea4ed3ea96f92f6
1 parent
91d5a988
feat(p3b): W14 下达任务入口;第二次真 CLI 冒烟验证两个硬缺陷修复
## W14 · POST /:cid/tasks 补上前端调用方
该端点此前**全仓库没有任何前端调用方** —— 界面走查确认过:用户在控制台上
没有任何办法给公司下达任务,只能 curl。这是「实现在、零调用方」在 P3 里的
第 N 次出现(W8 刚给 replan 补上,tasks 还欠着)。
新增 DispatchTaskButton.vue:
- 手动/自动**在下达时就选**,不是跑起来再改 —— execution_mode 是 run 的属性,
起 run 时定;后补一个下拉会让人以为能中途换
- 自动模式在这一步就给成本预警(真 CLI 一次中等任务 $10-20 / 1-2 小时),
GraphRunPane 的「启动」那道确认是针对已有 run 的,下达是更早的闸门
- 409(v1 并发约束)连同服务端 hint 原样透出,比翻译成「出错了」有用
- run 在跑时按钮直接不出现,而不是让人点了吃 409
挂载:有图未跑 run 时给主操作位(这时候它是页面唯一该做的事),
有 run 时收进标题栏。顺手修 .left-actions 缺 gap 导致两个按钮重叠。
## 第二次真 CLI 冒烟(worktree 隔离,主仓库 data/ 零写入)
链路比上一轮走得远得多,两个硬缺陷的修复都在真跑中直接验证:
1. **R-18.1 端到端成立**:POST /create 自动触发 orggraph-planning 真跑,
它自己把图落库(3 节点 4 边,含两条 backward fail 边)**并自己雇了
org-planner / org-coder / org-validator** —— Step 7 的招聘真执行了。
2. **W5/W6 自雇再次验证,且规划质量明显更高**:workgraph-planning 规划出
brd → prd → benchmark → validator 四节点主干,两条回退边一条带
fail_reason=input_underspecified、一条无 reason 作兜底 —— 它自己用上了
D.2.2 的细分路由并满足 fail-fallback-edge 规则。四个本体全部自雇。
3. **守卫归属修复直接命中**:timeline 里
「本次调用窗口内有 1 处 agent 运行态文件改动,无法归属到本节点,不计入越界
unattributable = ['M system/agents/agent-creator/.claude/scheduled_tasks.json']」
——正是上一轮把成功节点判成 fail 的那个文件,这次被正确摘出,
org-planner-brd → **success**(上一轮它跑起来了但被误判)。
4. tokens 真的在计量(24,218),mock 下恒为 0。
产出位置正确:brd.md 落在 runs/<rootRunId>/nodes/<nodeRunId>/,
项目根 projects/p-4hjo36 按 root run 分配。worktree 源码跑完已复原到跑前状态。
测试 760/760 全绿(72 文件)。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
2 changed files
with
194 additions
and
1 deletions
web/src/components/graph-console/DispatchTaskButton.vue
0 → 100644
| 1 | +<!-- | ||
| 2 | + 下达任务入口(27-P3B W14) | ||
| 3 | + | ||
| 4 | + `POST /api/company/:cid/tasks` 此前**全仓库没有任何前端调用方** —— 界面走查时确认过: | ||
| 5 | + 用户在控制台上没有任何办法给公司下达任务,只能 curl。这是「实现在、零调用方」那类问题 | ||
| 6 | + 在 P3 里的第 N 次出现(W8 刚给 replan 补上调用方,tasks 还欠着)。 | ||
| 7 | + | ||
| 8 | + 两处刻意的设计: | ||
| 9 | + - **手动/自动在下达时就要选**,不是跑起来再改:`execution_mode` 是 run 的属性, | ||
| 10 | + 起 run 时定;界面上后补一个下拉会让人以为能中途换。 | ||
| 11 | + - **自动模式在这里就给成本预警**:真 CLI 一次中等任务是 $3-8 / 15-40 分钟 | ||
| 12 | + (27-P3B §五),点下去才知道就晚了。GraphRunPane 的「启动」也有一道确认, | ||
| 13 | + 但那是针对已有 run 的,下达这一步是更早的闸门。 | ||
| 14 | +--> | ||
| 15 | +<template> | ||
| 16 | + <div class="dispatch" :class="{ open }"> | ||
| 17 | + <button v-if="!open" class="btn" :class="variant" @click="open = true"> | ||
| 18 | + ✦ 下达任务 | ||
| 19 | + </button> | ||
| 20 | + | ||
| 21 | + <div v-else class="form"> | ||
| 22 | + <label class="lbl">任务描述</label> | ||
| 23 | + <textarea | ||
| 24 | + ref="ta" | ||
| 25 | + v-model="task" | ||
| 26 | + class="ta" | ||
| 27 | + rows="3" | ||
| 28 | + placeholder="用一句自然语言说清要做什么,例如:实现一个最小可运行的接口,错误输入要有明确响应" | ||
| 29 | + :disabled="busy" | ||
| 30 | + @keydown.meta.enter="submit" | ||
| 31 | + @keydown.ctrl.enter="submit" | ||
| 32 | + /> | ||
| 33 | + | ||
| 34 | + <label class="lbl">执行模式</label> | ||
| 35 | + <div class="modes"> | ||
| 36 | + <label class="mode" :class="{ on: mode === 'manual' }"> | ||
| 37 | + <input v-model="mode" type="radio" value="manual" :disabled="busy" /> | ||
| 38 | + <span class="m-t">手动</span> | ||
| 39 | + <span class="m-d">每点一次「下一步」推进一个节点</span> | ||
| 40 | + </label> | ||
| 41 | + <label class="mode" :class="{ on: mode === 'auto' }"> | ||
| 42 | + <input v-model="mode" type="radio" value="auto" :disabled="busy" /> | ||
| 43 | + <span class="m-t">自动</span> | ||
| 44 | + <span class="m-d">一次授权,从头跑到尾</span> | ||
| 45 | + </label> | ||
| 46 | + </div> | ||
| 47 | + | ||
| 48 | + <p v-if="mode === 'auto'" class="warn"> | ||
| 49 | + ⚠️ 自动模式会连续调用真实 agent。一次中等任务约 <b>60-100 次调用 / $10-20 / 1-2 小时</b>; | ||
| 50 | + 跑起来后可以随时「暂停」,但已经发出的那次调用不会被打断。 | ||
| 51 | + </p> | ||
| 52 | + | ||
| 53 | + <p v-if="err" class="err">{{ err }}<span v-if="hint" class="hint"> · {{ hint }}</span></p> | ||
| 54 | + | ||
| 55 | + <div class="acts"> | ||
| 56 | + <button class="btn primary" :disabled="!task.trim() || busy" @click="submit"> | ||
| 57 | + {{ busy ? '下达中…' : '下达' }} | ||
| 58 | + </button> | ||
| 59 | + <button class="btn ghost" :disabled="busy" @click="cancel">取消</button> | ||
| 60 | + <span class="kbd">⌘/Ctrl + Enter</span> | ||
| 61 | + </div> | ||
| 62 | + </div> | ||
| 63 | + </div> | ||
| 64 | +</template> | ||
| 65 | + | ||
| 66 | +<script setup> | ||
| 67 | +import { ref, nextTick, watch } from 'vue'; | ||
| 68 | + | ||
| 69 | +const props = defineProps({ | ||
| 70 | + companyId: { type: String, required: true }, | ||
| 71 | + variant: { type: String, default: 'primary' }, // primary | inline | ||
| 72 | +}); | ||
| 73 | +const emit = defineEmits(['dispatched']); | ||
| 74 | + | ||
| 75 | +const open = ref(false); | ||
| 76 | +const task = ref(''); | ||
| 77 | +const mode = ref('manual'); | ||
| 78 | +const busy = ref(false); | ||
| 79 | +const err = ref(''); | ||
| 80 | +const hint = ref(''); | ||
| 81 | +const ta = ref(null); | ||
| 82 | + | ||
| 83 | +watch(open, async (v) => { | ||
| 84 | + if (!v) return; | ||
| 85 | + await nextTick(); | ||
| 86 | + ta.value?.focus(); | ||
| 87 | +}); | ||
| 88 | + | ||
| 89 | +function cancel() { | ||
| 90 | + open.value = false; | ||
| 91 | + err.value = ''; | ||
| 92 | + hint.value = ''; | ||
| 93 | +} | ||
| 94 | + | ||
| 95 | +async function submit() { | ||
| 96 | + if (!task.value.trim() || busy.value) return; | ||
| 97 | + busy.value = true; | ||
| 98 | + err.value = ''; | ||
| 99 | + hint.value = ''; | ||
| 100 | + try { | ||
| 101 | + const r = await fetch(`/api/company/${props.companyId}/tasks`, { | ||
| 102 | + method: 'POST', | ||
| 103 | + headers: { 'Content-Type': 'application/json' }, | ||
| 104 | + body: JSON.stringify({ task: task.value.trim(), mode: mode.value }), | ||
| 105 | + }); | ||
| 106 | + const d = await r.json().catch(() => ({})); | ||
| 107 | + if (!r.ok || !d.runId) { | ||
| 108 | + // 409 = v1 并发约束「一家公司同时只有一个 active root run」,服务端会带 hint, | ||
| 109 | + // 原样透出去比翻译成「出错了」有用得多 | ||
| 110 | + err.value = d.error || `下达失败(HTTP ${r.status})`; | ||
| 111 | + hint.value = d.hint || ''; | ||
| 112 | + return; | ||
| 113 | + } | ||
| 114 | + open.value = false; | ||
| 115 | + task.value = ''; | ||
| 116 | + emit('dispatched', d); | ||
| 117 | + } catch (e) { | ||
| 118 | + err.value = `下达失败:${e.message}`; | ||
| 119 | + } finally { | ||
| 120 | + busy.value = false; | ||
| 121 | + } | ||
| 122 | +} | ||
| 123 | +</script> | ||
| 124 | + | ||
| 125 | +<style scoped> | ||
| 126 | +/* 收起时按内容宽,撑满会盖住同一行的「重新规划组织」;展开时才占满整列 */ | ||
| 127 | +.dispatch { min-width: 0; } | ||
| 128 | +.dispatch.open { width: 100%; } | ||
| 129 | +.btn { | ||
| 130 | + border: 1px solid var(--border-primary); background: var(--bg-surface); color: var(--text-primary); | ||
| 131 | + border-radius: 6px; padding: 6px 12px; font-size: 13px; cursor: pointer; | ||
| 132 | +} | ||
| 133 | +.btn:disabled { opacity: .5; cursor: not-allowed; } | ||
| 134 | +.btn.primary { background: var(--accent, #2563eb); border-color: transparent; color: #fff; font-weight: 600; } | ||
| 135 | +.btn.inline { padding: 4px 10px; font-size: 12px; } | ||
| 136 | +.btn.ghost { background: transparent; } | ||
| 137 | +.form { display: flex; flex-direction: column; gap: 6px; text-align: left; } | ||
| 138 | +.lbl { font-size: 11px; color: var(--text-secondary); font-weight: 600; } | ||
| 139 | +.ta { | ||
| 140 | + width: 100%; box-sizing: border-box; resize: vertical; font: inherit; font-size: 13px; | ||
| 141 | + padding: 8px; border: 1px solid var(--border-primary); border-radius: 6px; | ||
| 142 | + background: var(--bg-surface); color: var(--text-primary); | ||
| 143 | +} | ||
| 144 | +.modes { display: flex; gap: 6px; } | ||
| 145 | +.mode { | ||
| 146 | + flex: 1; display: flex; flex-direction: column; gap: 2px; cursor: pointer; | ||
| 147 | + border: 1px solid var(--border-primary); border-radius: 6px; padding: 6px 8px; | ||
| 148 | +} | ||
| 149 | +.mode.on { border-color: var(--accent, #2563eb); background: var(--bg-overlay); } | ||
| 150 | +.mode input { display: none; } | ||
| 151 | +.m-t { font-size: 12px; font-weight: 700; } | ||
| 152 | +.m-d { font-size: 11px; color: var(--text-secondary); line-height: 1.3; } | ||
| 153 | +.warn { | ||
| 154 | + margin: 0; font-size: 11px; line-height: 1.45; color: var(--text-secondary); | ||
| 155 | + background: var(--bg-overlay); border: 1px dashed var(--border-primary); | ||
| 156 | + border-radius: 6px; padding: 6px 8px; | ||
| 157 | +} | ||
| 158 | +.err { margin: 0; font-size: 12px; color: #dc2626; line-height: 1.4; } | ||
| 159 | +.err .hint { color: var(--text-secondary); } | ||
| 160 | +.acts { display: flex; align-items: center; gap: 6px; } | ||
| 161 | +.kbd { margin-left: auto; font-size: 10px; color: var(--text-secondary); } | ||
| 162 | +</style> |
web/src/views/GraphConsole.vue
| @@ -37,6 +37,19 @@ | @@ -37,6 +37,19 @@ | ||
| 37 | <!-- 有图时「重新规划组织」是低频操作,收在标题栏上方即可;空态另有大按钮(R-18.1 / W8) --> | 37 | <!-- 有图时「重新规划组织」是低频操作,收在标题栏上方即可;空态另有大按钮(R-18.1 / W8) --> |
| 38 | <div v-if="hasOrgGraph || rootRun" class="left-actions"> | 38 | <div v-if="hasOrgGraph || rootRun" class="left-actions"> |
| 39 | <ReplanOrgGraphButton :company-id="companyId" has-graph variant="inline" @planned="onReplanned" /> | 39 | <ReplanOrgGraphButton :company-id="companyId" has-graph variant="inline" @planned="onReplanned" /> |
| 40 | + <!-- run 在跑时不给下达入口:v1 约束是一家公司同时只有一个 active root run, | ||
| 41 | + 端点会 409,与其让人点了吃报错,不如按钮就别出现(W14) --> | ||
| 42 | + <DispatchTaskButton | ||
| 43 | + v-if="!rootRunActive" | ||
| 44 | + :company-id="companyId" | ||
| 45 | + variant="inline" | ||
| 46 | + @dispatched="onDispatched" | ||
| 47 | + /> | ||
| 48 | + </div> | ||
| 49 | + | ||
| 50 | + <!-- 有图、但从没跑过 run:这时候「下达任务」才是这个页面唯一该做的事,给主操作位 --> | ||
| 51 | + <div v-if="hasOrgGraph && !rootRun" class="dispatch-lead"> | ||
| 52 | + <DispatchTaskButton :company-id="companyId" variant="primary" @dispatched="onDispatched" /> | ||
| 40 | </div> | 53 | </div> |
| 41 | 54 | ||
| 42 | <GraphRunPane | 55 | <GraphRunPane |
| @@ -126,6 +139,7 @@ import WorkGraphCanvas from '@/components/WorkGraphCanvas.vue'; | @@ -126,6 +139,7 @@ import WorkGraphCanvas from '@/components/WorkGraphCanvas.vue'; | ||
| 126 | import GraphRunPane from '@/components/graph-console/GraphRunPane.vue'; | 139 | import GraphRunPane from '@/components/graph-console/GraphRunPane.vue'; |
| 127 | import NodeDetailPanel from '@/components/graph-console/NodeDetailPanel.vue'; | 140 | import NodeDetailPanel from '@/components/graph-console/NodeDetailPanel.vue'; |
| 128 | import ReplanOrgGraphButton from '@/components/graph-console/ReplanOrgGraphButton.vue'; | 141 | import ReplanOrgGraphButton from '@/components/graph-console/ReplanOrgGraphButton.vue'; |
| 142 | +import DispatchTaskButton from '@/components/graph-console/DispatchTaskButton.vue'; | ||
| 129 | 143 | ||
| 130 | /** | 144 | /** |
| 131 | * 执行控制台(T3 + T4,§四.B)。三栏:org graph / work graph 画布 / 节点详情。 | 145 | * 执行控制台(T3 + T4,§四.B)。三栏:org graph / work graph 画布 / 节点详情。 |
| @@ -152,6 +166,9 @@ const rootState = ref({}); | @@ -152,6 +166,9 @@ const rootState = ref({}); | ||
| 152 | const midState = ref({ loopRecords: [], events: [] }); | 166 | const midState = ref({ loopRecords: [], events: [] }); |
| 153 | 167 | ||
| 154 | const hasOrgGraph = computed(() => defNodes.value.length > 0); | 168 | const hasOrgGraph = computed(() => defNodes.value.length > 0); |
| 169 | +/** run 在跑时端点会按 v1 并发约束 409,所以下达入口这时候直接不出现(W14) */ | ||
| 170 | +const ACTIVE_RUN = new Set(['ready', 'running', 'paused', 'blocked']); | ||
| 171 | +const rootRunActive = computed(() => !!rootRun.value && ACTIVE_RUN.has(rootRun.value.run_status)); | ||
| 155 | const currentRunId = computed(() => (stack.value.length ? stack.value[stack.value.length - 1].runId : null)); | 172 | const currentRunId = computed(() => (stack.value.length ? stack.value[stack.value.length - 1].runId : null)); |
| 156 | const budget = computed(() => rootState.value.budget || null); | 173 | const budget = computed(() => rootState.value.budget || null); |
| 157 | const spent = computed(() => budget.value?.spent || {}); | 174 | const spent = computed(() => budget.value?.spent || {}); |
| @@ -227,6 +244,14 @@ async function onReplanned() { | @@ -227,6 +244,14 @@ async function onReplanned() { | ||
| 227 | await Promise.all([loadOrgDef(), loadRuns()]); | 244 | await Promise.all([loadOrgDef(), loadRuns()]); |
| 228 | } | 245 | } |
| 229 | 246 | ||
| 247 | +/** 下达成功 → 立刻把新 run 拉进来,用户不用手动刷新(W14) */ | ||
| 248 | +async function onDispatched() { | ||
| 249 | + orgNode.value = null; | ||
| 250 | + selectedNode.value = null; | ||
| 251 | + stack.value = []; | ||
| 252 | + await loadRuns(); | ||
| 253 | +} | ||
| 254 | + | ||
| 230 | function onRootState(s) { | 255 | function onRootState(s) { |
| 231 | rootState.value = s; | 256 | rootState.value = s; |
| 232 | // root run 的 childRuns 就是各 org node 这次跑的 work run。allRuns 只在挂载时拉过一次, | 257 | // root run 的 childRuns 就是各 org node 这次跑的 work run。allRuns 只在挂载时拉过一次, |
| @@ -316,7 +341,13 @@ function fmt(n) { return n == null ? '∞' : Number(n).toLocaleString(); } | @@ -316,7 +341,13 @@ function fmt(n) { return n == null ? '∞' : Number(n).toLocaleString(); } | ||
| 316 | .history { display:flex; align-items:center; gap:8px; margin-bottom:8px; font-size:12px; color:#666; } | 341 | .history { display:flex; align-items:center; gap:8px; margin-bottom:8px; font-size:12px; color:#666; } |
| 317 | .history select { font-size:11px; padding:3px 6px; border:1px solid #ddd; border-radius:4px; background:#fff; max-width:100%; } | 342 | .history select { font-size:11px; padding:3px 6px; border:1px solid #ddd; border-radius:4px; background:#fff; max-width:100%; } |
| 318 | 343 | ||
| 319 | -.left-actions { display:flex; justify-content:flex-end; margin-bottom:8px; } | 344 | +.left-actions { |
| 345 | + display:flex; gap:6px; flex-wrap:wrap; align-items:flex-start; | ||
| 346 | + justify-content:flex-end; margin-bottom:8px; padding:8px 12px 0; | ||
| 347 | +} | ||
| 348 | + | ||
| 349 | +/* 有图未跑:下达任务是这时候唯一该做的事,给它主操作的位置与留白 */ | ||
| 350 | +.dispatch-lead { padding: 10px 12px; border-bottom: 1px solid var(--border-primary); } | ||
| 320 | 351 | ||
| 321 | /* 左栏空态:控制台在无图公司上唯一能做的事,所以按钮是主操作而不是脚注 */ | 352 | /* 左栏空态:控制台在无图公司上唯一能做的事,所以按钮是主操作而不是脚注 */ |
| 322 | .org-empty { text-align:center; color:#888; padding:34px 10px; } | 353 | .org-empty { text-align:center; color:#888; padding:34px 10px; } |