From c9b7a6534c92c01ceb1ee2cba2d342502f6fce4a Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sun, 18 Oct 2026 07:53:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(commondev):=20=E5=AE=8C=E6=88=90=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E5=9B=9E=E5=A1=AB=E6=8A=95=E9=80=92=E5=8F=B0=E8=B4=A6?= =?UTF-8?q?=EF=BC=88=E5=8E=9F=E5=85=88=E6=B0=B8=E8=BF=9C=E5=81=9C=E5=9C=A8?= =?UTF-8?q?=20accepted=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 缺陷(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":` 本身的长度再找。 --- example/commondev/delivery_backfill_test.go | 176 ++++++++++++++++++++ example/commondev/plugin.go | 43 ++++- example/commondev/sidecar.go | 4 + 3 files changed, 221 insertions(+), 2 deletions(-) create mode 100644 example/commondev/delivery_backfill_test.go diff --git a/example/commondev/delivery_backfill_test.go b/example/commondev/delivery_backfill_test.go new file mode 100644 index 0000000..e31e04d --- /dev/null +++ b/example/commondev/delivery_backfill_test.go @@ -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) + } + } +} diff --git a/example/commondev/plugin.go b/example/commondev/plugin.go index 6cb8595..e358a19 100644 --- a/example/commondev/plugin.go +++ b/example/commondev/plugin.go @@ -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) { diff --git a/example/commondev/sidecar.go b/example/commondev/sidecar.go index 1166e78..7526deb 100644 --- a/example/commondev/sidecar.go +++ b/example/commondev/sidecar.go @@ -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