fix(commondev): 完成通知回填投递台账(原先永远停在 accepted)

## 缺陷(2026-10-18 本机实例日志实测)

异步投递上线后,台账里的记录全是 accepted、result 永远为空:

	07:39:09 accepted result=None
	07:40:09 accepted result=None
	07:41:18 accepted result=None     ← 13 条全是

而回投本身**是正常的**([plugin] 日志有 5 次 `回投任务结果…event=prompt_done`)。
缺的是:完成通知到达后没人把它写回台账。

实测后果(同一天日志,agent 自己起的轮询):

	[timer] firing: 【轮询轮次 2/6】…检查是否有新 result 回填…

agent 为此安排了 6 轮 timer 去等一个**永远不会出现**的回填,白等 30 分钟。
更根本的是:看板与任何查询方都拿不到任务结果。

## 修法

`completeDelivery`:把完成/失败结果回填到该 branch 下**最新一条**
未回填的 accepted。`recordDelivery` 改为返回 key(追溯用)。

匹配用"最新未完成"而不是按 key:完成通知里没有 key(sidecar 不知道它),
而同一 branch 的投递是串行投给同一个 pi 会话的,最新未完成那条就是它。

## 判据(6 条)

回填最新 / 不串 branch / 不覆盖已回填 / 无匹配不 panic / 返回 key /
**完成通知真的调用了回填**(接线判据)

## ★ 写判据时踩的两个坑(都记在注释里)

1. **判据没覆盖接线 → 变异不咬**:前 5 条测的是 completeDelivery 自身行为。
   把 sidecar.go 里**那一行调用删掉**,它们依然全绿 —— 而"函数写得对但
   没人调"正是原始缺陷的形态。补了第 6 条读源码验接线。

2. **偏移错位导致假绿**:分段用 `seg := block[k:]` 后找 "case ",会命中
   k 自己(n=0)⇒ seg 不截断、含整个函数剩余部分 ⇒ done 分支删掉回填也照样
   "找到" error 分支那一行。改成跳过 `case "x":` 本身的长度再找。
This commit is contained in:
JianFeeeee
2026-10-18 07:53:45 +08:00
parent 4387e12ca8
commit c9b7a6534c
3 changed files with 221 additions and 2 deletions

View File

@ -0,0 +1,176 @@
package main
// 投递台账**结果回填**的判据(2026-10-18 生产实测缺陷)。
//
// ## 缺陷
//
// 异步投递后,投递那一刻只记 "accepted";真实结果几十秒后才由完成通知
// 送回。但原先**没人把它写回台账** ⇒ 台账永远停在 accepted:
//
// 2026-10-18T07:39:09 accepted result=None
// 2026-10-18T07:40:09 accepted result=None
// 2026-10-18T07:41:18 accepted result=None ← 13 条全是 None
//
// 实测后果(同一天的日志):
//
// [timer] firing: 【轮询轮次 2/6】…检查是否有新 result 回填…
//
// agent 自己起了 6 轮 timer 去轮询"有没有新 result"——而那件事永远不会发生。
//
// ## 判据
//
// 1. 完成通知必须回填最新那条 accepted
// 2. 只回填该 branch 的,不串到别的工程线
// 3. 已回填的不会被第二条通知重复覆盖(幂等边界)
// 4. 无匹配时不能 panic / 不能误改别的记录
import (
"os"
"strings"
"testing"
)
func newLedgerPlugin(t *testing.T) *Plugin {
t.Helper()
dir := t.TempDir()
p := &Plugin{path: dir + "/plugin.bin", ledger: &Ledger{}}
p.ledger.Branches = map[string]*Branch{}
return p
}
// ★★★ 判据:完成通知回填最新 accepted。
func TestCompleteDeliveryBackfillsLatestAccepted(t *testing.T) {
p := newLedgerPlugin(t)
p.recordDelivery("br-a", "sess-1", "任务一", "accepted", "")
p.recordDelivery("br-a", "sess-1", "任务二", "accepted", "")
p.completeDelivery("br-a", "sess-2", "done", "改好了 multiply")
ds := p.ledger.Deliveries
if len(ds) != 2 {
t.Fatalf("投递条数应不变(2),实际 %d", len(ds))
}
// 最新那条被回填
if ds[1].Status != "done" || ds[1].Result != "改好了 multiply" {
t.Fatalf("★ 最新 accepted 未回填:status=%q result=%q", ds[1].Status, ds[1].Result)
}
if ds[1].SessionID != "sess-2" {
t.Errorf("session 应更新为 sess-2,实际 %q", ds[1].SessionID)
}
// 更早那条不能被误改
if ds[0].Status != "accepted" || ds[0].Result != "" {
t.Fatalf("★ 误改了更早的记录:status=%q result=%q", ds[0].Status, ds[0].Result)
}
}
// ★★★ 判据:只回填同一 branch,不串线。
func TestCompleteDeliveryDoesNotCrossBranches(t *testing.T) {
p := newLedgerPlugin(t)
p.recordDelivery("br-a", "s1", "a 的任务", "accepted", "")
p.recordDelivery("br-b", "s2", "b 的任务", "accepted", "")
p.completeDelivery("br-a", "s1", "done", "a 完成")
if p.ledger.Deliveries[0].Status != "done" {
t.Fatal("br-a 的记录应被回填")
}
if p.ledger.Deliveries[1].Status != "accepted" {
t.Fatalf("★ br-b 的记录被误改:status=%q", p.ledger.Deliveries[1].Status)
}
}
// ★★★ 判据:已回填的记录不会被第二条通知覆盖(同一投递只认第一次结果)。
func TestCompleteDeliveryIsNotOverwritten(t *testing.T) {
p := newLedgerPlugin(t)
p.recordDelivery("br-a", "s1", "任务", "accepted", "")
p.completeDelivery("br-a", "s1", "done", "第一次结果")
p.completeDelivery("br-a", "s1", "failed", "第二次结果")
d := p.ledger.Deliveries[0]
if d.Status != "done" || d.Result != "第一次结果" {
t.Fatalf("★ 已回填的记录被第二条通知覆盖:status=%q result=%q", d.Status, d.Result)
}
}
// ★★★ 判据:没有待回填记录时不 panic、不改任何东西。
func TestCompleteDeliveryNoMatchIsSafe(t *testing.T) {
p := newLedgerPlugin(t)
p.recordDelivery("br-a", "s1", "任务", "failed", "同步就失败了")
// 这条是 failed,不是 accepted ⇒ 不该被回填
p.completeDelivery("br-a", "s1", "done", "迟到的通知")
if p.ledger.Deliveries[0].Status != "failed" {
t.Fatalf("★ 非 accepted 的记录被回填了:%q", p.ledger.Deliveries[0].Status)
}
// 完全无记录也不该 panic
p.completeDelivery("no-such-branch", "", "done", "x")
}
// ★★★ 判据:recordDelivery 必须返回 key(回填/追溯的前提)。
func TestRecordDeliveryReturnsKey(t *testing.T) {
p := newLedgerPlugin(t)
k := p.recordDelivery("br-a", "s1", "任务", "accepted", "")
if k == "" {
t.Fatal("recordDelivery 应返回非空 key(否则无法追溯某次投递)")
}
if p.ledger.Deliveries[0].Key != k {
t.Fatalf("返回的 key 与台账记录不一致:%q vs %q", k, p.ledger.Deliveries[0].Key)
}
}
// ★★★ 判据:**完成通知必须真的调用回填**(接线,不只是函数存在)。
//
// 为什么需要这条(2026-10-18 实测判据漏洞):
// 上面四条测的是 `completeDelivery` 自身行为。但我把 sidecar.go 里
// **那一行调用删掉**之后,它们依然全绿 —— 因为没人再调它了。
// 而这正是原始缺陷的形态:函数写得对,但没人用。
//
// ⇒ 判据必须覆盖接线。这里读 sidecar.go 的 `makePromptNotifier` 块,
//
// 要求 prompt_done / prompt_error 两条分支里都出现 completeDelivery 调用。
//
// ★ 用**行扫描界定块边界**而不是正则:正则遇到嵌套大括号容易提前结束
//
// (本仓在别处踩过这个坑)。
func TestPromptNotifierWiresCompleteDelivery(t *testing.T) {
b, err := os.ReadFile("sidecar.go")
if err != nil {
t.Fatal(err)
}
src := string(b)
i := strings.Index(src, "func (p *Plugin) makePromptNotifier")
if i < 0 {
t.Fatal("找不到 makePromptNotifier")
}
block := src[i:]
if j := strings.Index(block[1:], "\nfunc "); j > 0 {
block = block[:j+1]
}
for _, ev := range []string{"prompt_done", "prompt_error"} {
k := strings.Index(block, `case "`+ev+`":`)
if k < 0 {
t.Fatalf("★ makePromptNotifier 里没有 %s 分支", ev)
}
// 该分支到**下一个** case 之间必须出现回填调用。
//
// ★ 偏移必须跳过当前 "case " 本身:从 k 开始找 "case " 会命中
// k 自己(n=0),于是 seg 不被截断、包含整个函数剩余部分 ——
// 那样 prompt_done 分支即便**删掉**回填调用也照样能"找到"
// prompt_error 分支里的那一行 ⇒ 判据假绿。
// 实测踩过:变异删掉 done 分支的回填,判据仍然 PASS。
seg := block[k:]
lead := len(`case "` + ev + `":`)
if n := strings.Index(seg[lead:], "case "); n >= 0 {
seg = seg[:lead+n]
}
if !strings.Contains(seg, "p.completeDelivery(") {
t.Fatalf("★ %s 分支**没有调用 completeDelivery** —— "+
"结果是:台账永远停在 accepted,看板与 agent 都查不到任何结果\n"+
"(实测后果:agent 起了 6 轮 timer 空等一个永远不会出现的结果)", ev)
}
}
}

View File

@ -1380,11 +1380,13 @@ func (p *Plugin) deliverToPi(branchID string, cargs map[string]interface{}) (int
}, nil
}
func (p *Plugin) recordDelivery(branchID, sessionID, text, status, note string) {
// recordDelivery 记一条投递台账,返回它的 key(供完成时回填)。
func (p *Plugin) recordDelivery(branchID, sessionID, text, status, note string) string {
p.mu.Lock()
defer p.mu.Unlock()
key := fmt.Sprintf("%s-%d", branchID, time.Now().UnixNano())
p.ledger.Deliveries = append(p.ledger.Deliveries, Delivery{
Key: fmt.Sprintf("%s-%d", branchID, time.Now().UnixNano()),
Key: key,
BranchID: branchID, Text: truncate(text, 200),
SessionID: sessionID, Status: status, Result: note,
At: time.Now().Format(time.RFC3339),
@ -1394,6 +1396,43 @@ func (p *Plugin) recordDelivery(branchID, sessionID, text, status, note string)
p.ledger.Deliveries = p.ledger.Deliveries[n-200:]
}
_ = p.saveLocked()
return key
}
// completeDelivery 把完成/失败结果**回填**到某条投递台账。
//
// ★ 为什么必须有它(2026-10-17 生产实测缺陷):
//
// 异步投递后,投递那一刻只能记 "accepted";真正的结果几十秒后才由
// 完成通知送回来。原先没人把它写回台账 ⇒ 台账永远停在 accepted,
// 看板/agent 都**查不到任何结果**。
// 实测后果:agent 自己起了一个 6 轮的 timer 去轮询"有没新 result 回填",
// 而那件事永远不会发生 —— 白等 30 分钟。
//
// 匹配策略:找该 branch 下**最新一条**尚未回填(Result 为空)的 accepted。
// 不做"按 key 精确匹配"是因为完成通知里没有 key(sidecar 不知道它)——
// 而同一 branch 的投递是串行投给同一个 pi 会话的,最新未完成那条就是它。
func (p *Plugin) completeDelivery(branchID, sessionID, status, result string) {
p.mu.Lock()
defer p.mu.Unlock()
for i := len(p.ledger.Deliveries) - 1; i >= 0; i-- {
d := &p.ledger.Deliveries[i]
if d.BranchID != branchID || d.Result != "" {
continue
}
if d.Status != "accepted" {
continue
}
d.Status = status
d.Result = truncate(result, 500)
if sessionID != "" {
d.SessionID = sessionID
}
_ = p.saveLocked()
log.Printf("[commondev] 台账回填:%s → %s", d.Key, status)
return
}
log.Printf("[commondev] 台账回填:branch=%s 没有待完成的 accepted 记录(通知可能早于投递落盘)", branchID)
}
func (p *Plugin) handleBranchStatus(args map[string]interface{}) (interface{}, error) {

View File

@ -654,9 +654,13 @@ func (p *Plugin) makePromptNotifier() func(branchID string, payload map[string]i
reply = "(pi 未返回文本回复)"
}
text = fmt.Sprintf("[工程线 %s 任务完成 · session=%s]\n%s", branchID, sess, reply)
// ★ 回填台账:否则台账永远停在 accepted,谁都查不到结果
// (实测:agent 为此起了 6 轮 timer 空等)。
p.completeDelivery(branchID, sess, "done", reply)
case "prompt_error":
e, _ := payload["error"].(string)
text = fmt.Sprintf("[工程线 %s 任务失败 · session=%s]\n错误:%s", branchID, sess, e)
p.completeDelivery(branchID, sess, "failed", e)
default:
log.Printf("[commondev] 未知的通知事件 %q(branch=%s),忽略", event, branchID)
return