Commit 3d9cb5bdb6b9f7a5328b24b2fea4ed3ea96f92f6

Authored by yibo
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>
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 &#39;@/components/WorkGraphCanvas.vue&#39;; @@ -126,6 +139,7 @@ import WorkGraphCanvas from &#39;@/components/WorkGraphCanvas.vue&#39;;
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 ? &#39;∞&#39; : Number(n).toLocaleString(); } @@ -316,7 +341,13 @@ function fmt(n) { return n == null ? &#39;∞&#39; : 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; }