Archived
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:
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 广播的**跨进程桥** —— 别的后端动了同一张单,这边的浏览器也得知道。
|
||||
*
|
||||
* 起因(2026-08-24 用户报的第三件事):「一直卡在 AI 推进中,刷新后才发现是已经推完了、
|
||||
* 需要人类确认了」。查下来推送本身是好的 —— 可 `createBroadcaster()` 是**进程内**的
|
||||
* 一个 Set:谁推进的,只有谁自己的 SSE 客户端收得到。而那会儿本机与局域网另一台
|
||||
* 各跑着一个后端连同一个库(同一批症状的另一面,见 `workers/task-claim.ts`):
|
||||
* 单子被那一台推着走,状态在库里早变了,**这一台的浏览器一个字都没收到** ——
|
||||
* 界面就停在「AI 推进中」,直到人手动刷新。
|
||||
*
|
||||
* 桥怎么搭:Postgres 自带的 `LISTEN/NOTIFY` —— 不引消息队列、不加部署件。
|
||||
* · 本进程 publish 一条 → 顺手 `NOTIFY`(带上进程标识);
|
||||
* · 一条**专用连接**常年 `LISTEN`,收到别的进程发来的就在本地 publish 一次。
|
||||
* **自己发的自己不再收**(靠进程标识认),不然一条推送在两台之间来回弹。
|
||||
*
|
||||
* 桥断了不影响业务:推送本就是「告诉在线浏览器去重取」的提示,丢一条最坏是
|
||||
* 那一屏晚两秒(前端还有轮询兜底)。所以这里所有错误都只记一行,不往上抛。
|
||||
*/
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import type { Broadcaster, Touch } from './broadcast.ts';
|
||||
|
||||
/** 频道名 —— 全库一个就够,载荷里带着 kind/id,订阅方自己分 */
|
||||
export const TOUCH_CHANNEL = 'jcai_touch';
|
||||
|
||||
/** 这一次进程的身份 —— 用来认「这条是我自己发的」,不必稳定、不必持久 */
|
||||
const SELF = `${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
type Wire = Touch & { readonly from: string };
|
||||
|
||||
/**
|
||||
* 把本地广播接到库上。返回 `stop()`;起不来不抛 —— 单进程照常跑,只是没有跨进程那一段。
|
||||
*
|
||||
* `notify` 用参数化的 `pg_notify` 而不是拼 `NOTIFY` 语句:载荷里有中文与引号,
|
||||
* 拼字符串迟早出事(而这条路上的字符串全来自业务)。
|
||||
*/
|
||||
export function bridgeBroadcaster(pool: Pool, broadcaster: Broadcaster): { stop(): void } {
|
||||
let listener: PoolClient | undefined;
|
||||
let stopped = false;
|
||||
|
||||
/** 本地 → 库:自己推的顺手 NOTIFY 一条(不 await,广播不该拖慢业务) */
|
||||
const unsubscribe = broadcaster.subscribe((touch) => {
|
||||
if (stopped) return;
|
||||
const wire: Wire = { ...touch, from: SELF };
|
||||
void pool
|
||||
.query('select pg_notify($1, $2)', [TOUCH_CHANNEL, JSON.stringify(wire)])
|
||||
.catch((error: unknown) => {
|
||||
process.stderr.write(`[broadcast-bridge] notify 失败:${error instanceof Error ? error.message : String(error)}\n`);
|
||||
});
|
||||
});
|
||||
|
||||
/** 库 → 本地:一条专用连接常年 LISTEN,收到别人的就在本地 publish */
|
||||
const connect = async (): Promise<void> => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
listener = client;
|
||||
client.on('notification', (message) => {
|
||||
if (message.channel !== TOUCH_CHANNEL || message.payload === undefined) return;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(message.payload);
|
||||
/*
|
||||
* **形状不对就丢** —— 频道是全库公用的,谁都能往里 NOTIFY 一句话。
|
||||
* 不校形状的话,一条 `"hello"` 会被当成 touch 广播出去:
|
||||
* 前端拿到 `kind: undefined` 谁也失效不了,还白跑一趟(界面不撒谎那一条,
|
||||
* 对推送同样算数)。
|
||||
*/
|
||||
if (typeof parsed !== 'object' || parsed === null) return;
|
||||
const wire = parsed as Partial<Wire>;
|
||||
if (typeof wire.kind !== 'string' || typeof wire.id !== 'string') return;
|
||||
// **自己发的自己不收** —— 否则一条推送在进程之间来回弹
|
||||
if (wire.from === SELF) return;
|
||||
broadcaster.publish({ kind: wire.kind as Touch['kind'], id: wire.id, reason: wire.reason ?? 'remote' });
|
||||
} catch {
|
||||
/* 载荷根本不是 JSON:丢这一条,不拖垮连接 */
|
||||
}
|
||||
});
|
||||
client.on('error', (error) => {
|
||||
process.stderr.write(`[broadcast-bridge] 连接断了,5 秒后重连:${error.message}\n`);
|
||||
client.release();
|
||||
listener = undefined;
|
||||
setTimeout(() => void connect(), 5000).unref();
|
||||
});
|
||||
await client.query(`listen ${TOUCH_CHANNEL}`);
|
||||
} catch (error) {
|
||||
process.stderr.write(`[broadcast-bridge] 接不上,5 秒后重试:${error instanceof Error ? error.message : String(error)}\n`);
|
||||
setTimeout(() => void connect(), 5000).unref();
|
||||
}
|
||||
};
|
||||
void connect();
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
unsubscribe();
|
||||
listener?.release();
|
||||
listener = undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user