mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-31 14:27:30 +00:00
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:
176
example/commondev/delivery_backfill_test.go
Normal file
176
example/commondev/delivery_backfill_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1380,11 +1380,13 @@ func (p *Plugin) deliverToPi(branchID string, cargs map[string]interface{}) (int
|
|||||||
}, nil
|
}, 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()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
|
key := fmt.Sprintf("%s-%d", branchID, time.Now().UnixNano())
|
||||||
p.ledger.Deliveries = append(p.ledger.Deliveries, Delivery{
|
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),
|
BranchID: branchID, Text: truncate(text, 200),
|
||||||
SessionID: sessionID, Status: status, Result: note,
|
SessionID: sessionID, Status: status, Result: note,
|
||||||
At: time.Now().Format(time.RFC3339),
|
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.ledger.Deliveries = p.ledger.Deliveries[n-200:]
|
||||||
}
|
}
|
||||||
_ = p.saveLocked()
|
_ = 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) {
|
func (p *Plugin) handleBranchStatus(args map[string]interface{}) (interface{}, error) {
|
||||||
|
|||||||
@ -654,9 +654,13 @@ func (p *Plugin) makePromptNotifier() func(branchID string, payload map[string]i
|
|||||||
reply = "(pi 未返回文本回复)"
|
reply = "(pi 未返回文本回复)"
|
||||||
}
|
}
|
||||||
text = fmt.Sprintf("[工程线 %s 任务完成 · session=%s]\n%s", branchID, sess, reply)
|
text = fmt.Sprintf("[工程线 %s 任务完成 · session=%s]\n%s", branchID, sess, reply)
|
||||||
|
// ★ 回填台账:否则台账永远停在 accepted,谁都查不到结果
|
||||||
|
// (实测:agent 为此起了 6 轮 timer 空等)。
|
||||||
|
p.completeDelivery(branchID, sess, "done", reply)
|
||||||
case "prompt_error":
|
case "prompt_error":
|
||||||
e, _ := payload["error"].(string)
|
e, _ := payload["error"].(string)
|
||||||
text = fmt.Sprintf("[工程线 %s 任务失败 · session=%s]\n错误:%s", branchID, sess, e)
|
text = fmt.Sprintf("[工程线 %s 任务失败 · session=%s]\n错误:%s", branchID, sess, e)
|
||||||
|
p.completeDelivery(branchID, sess, "failed", e)
|
||||||
default:
|
default:
|
||||||
log.Printf("[commondev] 未知的通知事件 %q(branch=%s),忽略", event, branchID)
|
log.Printf("[commondev] 未知的通知事件 %q(branch=%s),忽略", event, branchID)
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user