E04 修到零被拒线,harness 补七刀:跨进程租约与广播桥、原子写、引用墙、对话区按序号排

活动侧(E04 2.1.0 → 2.1.9,I01 1.2.2):
- 覆盖闭合成为机检:基座新增 distinct 读数,「计划盖住的客户数 = 候选行数」钉进
  S3 收步 / S4 哨兵 / 放行 L2;人加的候选客户不再被整单无视
- 「关键人待识别」占位句式全链退役:查无联系人先发数据录入卡收人;契约新增 refersTo,
  计划/回执的 contact_code 只许引用真实存在的联系人(安装期核引用目标)
- 台账更新确认摆全落库面:客户档案/联系人档案两张落库预览(touched: 数据源,
  正式现值 + 草稿叠加),可补改、可加行(自增主键由基座续号);姓名升必填
- 更新明细:update_key 位置键 + 「同一处只留一条」L2;依据写人话不带机器键;
  标题旁不摆单号;判据与文案不夹字段名;行标识去掉单标识那一段
- 回执骨架 = 计划行数钉进 S3 收步与 S4 哨兵;重做分支不许删计划凑数
- 阶段刻度对齐文档 7 步;起单样例预填满

基座侧:
- 两个后端连同一个库:task-claim 跨进程租约(迁移 0030)+ Postgres LISTEN/NOTIFY
  广播桥;对话区按任务内记忆序号排,不按墙上时钟;并卡「刚说过话」按记忆序列判紧邻
- 多行 write 整批先验后写(dryRun),被拒 = 一行都没落
- 决策卡意见与本单草稿字面比对,归属以观察进轨迹(note-anchors)
- 括号栈补「补闭括号」两形(少一个 } / 行对象没关直接跟下一行)
- 件上加行:空着的自增主键直进续号,不从别的行借号;未落下的行点名回给前端
- 人答复落库那一刻就把 runtime 翻 running(task.wake),两块同帧变脸
- 试跑单可落真库(land_master,迁移 0031,默认开);数据域按活动分组;
  JSON 列不再是 [object Object];按钮键感 + 在办进度条 + 防抖
- 报价链 A1–A7 从系统卸载(只卸不删源码),种子只装 E04/I01

契约变更(api-report 已刷新):FieldDeclaration.distinct / refersTo,
objectDeclaration.file,landMaster,unkeyed,assetFiles,ledgerByActivityRoute。
1700 测 / typecheck / lint / depcruise / api:check 绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEJX8Z7CHejvwmDQyPQuBX
This commit is contained in:
xcs
2026-08-25 01:23:30 +08:00
co-authored by Claude Fable 5
parent 92dd729c36
commit f3397a60ef
91 changed files with 3919 additions and 350 deletions
@@ -15,13 +15,29 @@
*
* DOM 与类名逐字照搬原型:皮肤是原型的原样,自造类名等于没样式。
*/
import { useEffect, useRef, useState } from 'react';
import { createContext, useContext, useEffect, useRef, useState } from 'react';
import type { FormField, AiTag, ChatCardReply, ChatMessage, WorkOrder, WorkOrderReply } from '@contracts';
import { EVENT_TYPE_LABEL, isRejection } from '@contracts';
import { Ico } from '@web-shared/Ico.tsx';
import { Btn } from '@web-shared/Btn.tsx';
import { ChatAvatar } from '@web-shared/ChatAvatar.tsx';
import { atClock } from '@web-shared/at.ts';
/**
* 「这一下交上去了,回音还在路上」—— 卡族共用一个在办标记。
*
* 卡上的钮全都要走一趟服务端(交工单 / 裁决 / 选一支 / 答一张卡),而这一趟里
* 屏上从前**什么都不变**:人按下「提交」之后只能干等,常常再按一次。
* 逐层往下传 prop 要穿过 ChatStream → Msg → 各卡三四层,卡族又都在这一份文件里 ——
* 用一个上下文最省事,也最不容易漏掉某一张卡(漏掉的那张就是下一次「点了没反应」)。
*/
const ReplyBusyContext = createContext(false);
/** 卡上的钮据此说「在办事」;`ChatStream` 从任务面递进来 */
function useReplyBusy(): boolean {
return useContext(ReplyBusyContext);
}
/**
* 类型章:结论 = auto,指导 = exe,判据 = acc(原型 TAG_UI 口径)。
*
@@ -81,6 +97,8 @@ export type TaskChatProps = {
* 状态来自任务投影的 runtime,不是前端猜的。
*/
readonly working?: boolean;
/** 卡上刚交出去的那一笔还在路上 —— 卡上的钮据此说「在办事」 */
readonly replying?: boolean;
onSay(text: string): void;
onReplyCard(reply: ChatCardReply): void;
onReplyOrder(reply: WorkOrderReply): void;
@@ -98,6 +116,7 @@ export function ChatStream({
orders,
thinking,
working = false,
replying = false,
onSay,
onReplyCard,
onReplyOrder,
@@ -125,6 +144,7 @@ export function ChatStream({
};
return (
<ReplyBusyContext.Provider value={replying}>
<div className="jc-tch">
<header className="jc-tch-head">
<span className="jc-tch-mark">
@@ -186,12 +206,13 @@ export function ChatStream({
}
}}
/>
<button className="copilot-send" disabled={text.trim() === '' || blocked} onClick={send}>
<Btn className="copilot-send" disabled={text.trim() === '' || blocked} busy={thinking} onClick={send}>
<Ico name="send" s={14} /> 发送
</button>
</Btn>
</div>
</div>
</div>
</ReplyBusyContext.Provider>
);
}
@@ -274,6 +295,7 @@ function Msg({
orderValues?: Readonly<Record<string, string>> | undefined;
onField: (key: string, value: string) => void;
}) {
const busy = useReplyBusy();
// 人的消息:右侧品牌气泡(纯文本)
if (m.k === 'human') {
return (
@@ -495,9 +517,10 @@ function Msg({
{m.chosen === undefined ? (
<div className="jc-branches">
{m.options.map((o, i) => (
<button
<Btn
key={o.value}
className={'jc-branch' + (o.recommended === true ? ' rec' : '')}
busy={busy}
onClick={() => onReplyCard({ k: 'decision', cardId: m.id, value: o.value })}
>
<span className="jc-branch-n jc-mono">{String.fromCharCode(65 + i)}</span>
@@ -514,7 +537,7 @@ function Msg({
)}
</span>
<Ico name="chevron-right" s={14} />
</button>
</Btn>
))}
{m.allowCustom && (
<CustomOption
@@ -767,6 +790,7 @@ function ExecForm({
orderValues?: Readonly<Record<string, string>> | undefined;
onField: (key: string, value: string) => void;
}) {
const busy = useReplyBusy();
const val = (f: FormField) => orderValues?.[f.key] ?? f.ai ?? '';
/*
* **必填还空着就按不下提交**(2026-08-02 用户定)——两处必填,一个门:
@@ -842,9 +866,10 @@ function ExecForm({
</div>
)}
<div className="jc-fc-ft">
<button
<Btn
className="jc-btn jc-fc-go"
disabled={!ok}
busy={busy}
onClick={() =>
onReplyOrder({
mode: 'execution',
@@ -854,7 +879,7 @@ function ExecForm({
}
>
<Ico name="check" s={13} /> {order.submitLabel ?? '提交'}
</button>
</Btn>
</div>
</div>
);
@@ -880,6 +905,7 @@ function AcceptForm({
*/
orderValues?: Readonly<Record<string, string>> | undefined;
}) {
const busy = useReplyBusy();
const [why, setWhy] = useState('');
/** 选择组:恰选一口,选口即裁分支 —— 没选之前「通过」不亮 */
const [port, setPort] = useState<string | undefined>(undefined);
@@ -971,9 +997,10 @@ function AcceptForm({
/>
</div>
<div className="jc-fc-ft">
<button
<Btn
className="jc-btn jc-btn-bad"
disabled={why.trim() === ''}
busy={busy}
onClick={() =>
onReplyOrder({
mode: 'acceptance',
@@ -987,11 +1014,12 @@ function AcceptForm({
}
>
重做
</button>
</Btn>
<span className="jc-fc-sp" />
<button
<Btn
className="jc-btn jc-btn-ok"
disabled={needChoice && port === undefined}
busy={busy}
title={needChoice && port === undefined ? `先在上面选一个「${choice.group}」` : undefined}
onClick={() =>
onReplyOrder({
@@ -1021,7 +1049,7 @@ function AcceptForm({
}
>
<Ico name="circle-check" s={14} /> {order.passLabel ?? '确认通过'}
</button>
</Btn>
</div>
</div>
);
@@ -1029,6 +1057,7 @@ function AcceptForm({
/* 决策的收尾项:自定义口径 */
function CustomOption({ n, onPick }: { n: string; onPick: (txt: string) => void }) {
const busy = useReplyBusy();
const [txt, setTxt] = useState('');
return (
<div className="jc-branch jc-branch-custom">
@@ -1044,9 +1073,9 @@ function CustomOption({ n, onPick }: { n: string; onPick: (txt: string) => void
if (e.key === 'Enter' && txt.trim() !== '') onPick(txt.trim());
}}
/>
<button className="jc-btn" disabled={txt.trim() === ''} onClick={() => onPick(txt.trim())}>
<Btn className="jc-btn" disabled={txt.trim() === ''} busy={busy} onClick={() => onPick(txt.trim())}>
就按这个
</button>
</Btn>
</span>
</span>
</div>
@@ -1068,6 +1097,7 @@ function FillForm({
* 人核对一眼、补上它填不出的那个即可。
* 用惰性初值而不是 `useEffect` 回填:回填会在人已经开始打字之后把他敲的字冲掉。
*/
const busy = useReplyBusy();
const [vals, setVals] = useState<Record<string, string>>(() =>
Object.fromEntries(fields.filter((f) => (f.value ?? '') !== '').map((f) => [f.key, f.value ?? ''])),
);
@@ -1093,9 +1123,9 @@ function FillForm({
<button type="button" className="jc-cant-link" onClick={() => setCant(false)}>
← 还是填吧
</button>
<button className="jc-btn" disabled={reason.trim() === ''} onClick={() => onCant(reason.trim())}>
<Btn className="jc-btn" disabled={reason.trim() === ''} busy={busy} onClick={() => onCant(reason.trim())}>
<Ico name="send" s={13} /> 让 AI 调整需求
</button>
</Btn>
</div>
</div>
);
@@ -1120,9 +1150,9 @@ function FillForm({
<button type="button" className="jc-cant-link" onClick={() => setCant(true)}>
没法填?告诉 AI 原因
</button>
<button className="jc-btn" disabled={!ok} onClick={() => onDone(vals)}>
<Btn className="jc-btn" disabled={!ok} busy={busy} onClick={() => onDone(vals)}>
<Ico name="check" s={13} /> 补给 AI
</button>
</Btn>
</div>
</div>
);
@@ -1130,6 +1160,7 @@ function FillForm({
/* 评价卡的打分器:星级 + 改进建议(建议进活动记忆) */
function RateForm({ onDone }: { onDone: (score: number, note: string) => void }) {
const busy = useReplyBusy();
const [score, setScore] = useState(0);
const [hover, setHover] = useState(0);
const [note, setNote] = useState('');
@@ -1164,9 +1195,9 @@ function RateForm({ onDone }: { onDone: (score: number, note: string) => void })
/>
</label>
<div className="jc-cfill-acts">
<button className="jc-btn" disabled={score === 0} onClick={() => onDone(score, note.trim())}>
<Btn className="jc-btn" disabled={score === 0} busy={busy} onClick={() => onDone(score, note.trim())}>
<Ico name="check" s={13} /> 提交评价
</button>
</Btn>
</div>
</div>
);
@@ -1192,6 +1223,7 @@ function EntryCard({
* 联系人由 AI 自己写 —— 而联系人档的 `contact_code` 是它编出来的,人一眼没看过。
* 值按段隔开(`段号.列名`):两张表都有 `name` 这一列,平铺就会互相盖掉。
*/
const busy = useReplyBusy();
const groups = [
{
title: '',
@@ -1290,9 +1322,9 @@ function EntryCard({
{!done && (
<div className="jc-cfill-acts">
{blank.length > 0 && <span className="jc-entry-warn">还差:{blank.map((f) => f.label).join('、')}</span>}
<button className="jc-btn" disabled={blank.length > 0} onClick={() => onDone(vals)}>
<Btn className="jc-btn" disabled={blank.length > 0} busy={busy} onClick={() => onDone(vals)}>
<Ico name="database" s={13} /> 确认写入
</button>
</Btn>
</div>
)}
</>