feat(planner): 触发上下文带实际变动 + 心跳触发改为「定时巡检」措辞

把 planner 每轮的触发信号从「固定一句"图发生了变化"」升级为具体变动:

- 统一 TriggerEvent{Kind,IntentID,Detail} 累加器(替换原 doneIntents):
  worker 结束→{done}, report_finding→{finding,摘要}。Task.NotifyDone/新增
  NotifyFinding 记录, drainTriggers 消费, Plan 参数改为 []TriggerEvent。
- renderTriggers 渲染:worker 结束→哪个意图(摘要)+worker 输出结论(取 result/text
  全文,截断800字);finding→哪个意图+finding 摘要。
- worker 回调 notify()→notifyFinding(intentID,summary),report_finding 落库带上下文。
- 时间/心跳触发(triggers 为空)不再谎称"图发生了变化",开场白改为「定时巡检」并
  提示复查在跑意图(steer_work 纠偏/kill_work 止损),对齐心跳的飞行中监督职责。
This commit is contained in:
Autumn-27 committed 2026-08-12 12:18:06 -04:00
1 parent 8e31a75263
commit 678025f850
6 files changed
+151 -50

No files matched your search

+6 -6
View File
@@ -511,12 +511,12 @@ func (e *Engine) plannerLoop(ctx context.Context, t *Task) {
// UI can separate rounds in the transcript (kind='round').
e.emitActivity(t, db.Activity{Worker: "planner", Kind: "round",
Summary: fmt.Sprintf("第 %d 轮规划", e.nextPlannerRound(t.ID))})
// which intents' completion triggered this round (may be several — debounce
// coalesces a burst; empty for non-completion wakes like a new hint / heartbeat).
doneIntents := t.drainDone()
// what fired this round (worker done / finding; may be several — debounce
// coalesces a burst; empty for time/heartbeat wakes).
triggers := t.drainTriggers()
e.incInflight(t.ID)
taskIDInt, _ := strconv.ParseInt(t.ID, 10, 64)
met, reason, err := planner.Plan(ectx, taskIDInt, e.m.assets, t.Store, t.Goal, doneIntents, emit)
met, reason, err := planner.Plan(ectx, taskIDInt, e.m.assets, t.Store, t.Goal, triggers, emit)
e.decInflight(t.ID)
switch {
case err != nil && ectx.Err() == nil:
@@ -611,7 +611,7 @@ func (e *Engine) workerLoop(ctx context.Context, t *Task, name string) {
hooks := steerHooks{inner: t.Guard.Hooks(), drain: func() (string, bool) { return e.drainSteer(iid) }}
wTaskID, _ := strconv.ParseInt(t.ID, 10, 64)
e.incInflight(t.ID) // 计入在跑,供收尾时序 drain 等待
reason, wrote, err := worker.Execute(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.Notify)
reason, wrote, err := worker.Execute(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.NotifyFinding)
// model_error 收场 → 额外重跑几次(退避后再试)。仅在意图仍属本 work、任务
// 未暂停/未终止/未取消【且未进入收尾】时重试;否则让位给对应分支处理(收尾期不
// 再重试,避免退避挤占其他 worker 的优雅收尾窗口)。
@@ -623,7 +623,7 @@ func (e *Engine) workerLoop(ctx context.Context, t *Task, name string) {
if sleepCtx(workCtx, modelErrorRetryBackoff) {
break // 退避期间被取消(终止/暂停)→ 交给下方分支处理
}
reason, wrote, err = worker.Execute(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.Notify)
reason, wrote, err = worker.Execute(workCtx, name, wTaskID, e.m.assets, t.Store, intent, hooks, emit, e.m.enrich, t.NotifyFinding)
}
e.decInflight(t.ID)
// CAPTURE kill state BEFORE unregisterWork cancels workCtx. kill = this work's
+1 -1
View File
@@ -209,7 +209,7 @@ func (e *Engine) runFinalPlannerRound(ctx context.Context, t *Task) (met bool) {
fctx := e.clockCtx(ctx, t, true)
e.incInflight(t.ID)
tTaskID, _ := strconv.ParseInt(t.ID, 10, 64)
met, reason, err := planner.Plan(fctx, tTaskID, e.m.assets, t.Store, t.Goal, t.drainDone(), emit)
met, reason, err := planner.Plan(fctx, tTaskID, e.m.assets, t.Store, t.Goal, t.drainTriggers(), emit)
e.decInflight(t.ID)
if err != nil {
log.Printf("[deadline] task %s 终局规划出错: %v", t.ID, err)
+28 -19
View File
@@ -44,11 +44,11 @@ type Task struct {
Guard *guard.Guard `json:"-"`
notify chan struct{}
// doneIntents accumulates the ids of intents that completed since the last
// planning round consumed them. The debounce coalesces a burst of completions
// into one round, so several ids may pile up before drainDone() clears them.
doneMu sync.Mutex
doneIntents []int64
// pendingTriggers accumulates the concrete changes (worker done / finding) that
// fired planning rounds since the last one consumed them. The debounce coalesces
// a burst into one round, so several may pile up before drainTriggers() clears them.
trigMu sync.Mutex
pendingTriggers []agent.TriggerEvent
}
// Manager owns the PostgreSQL data source (asset graph + every task's exploration
@@ -696,24 +696,33 @@ func (t *Task) Notify() {
}
}
// NotifyDone is Notify plus a hint: intentID just finished and is what triggered
// this wake-up. The planner reads the accumulated ids next round so it knows
// which intents' fresh yields to focus on. Ids pile up (debounce) until the
// round drains them via drainDone.
// NotifyDone is Notify plus a hint: a worker just finished intentID and that is
// what triggered this wake-up. The planner reads the accumulated triggers next
// round so it can spell out which intent finished (+ its output). Events pile up
// (debounce) until the round drains them via drainTriggers.
func (t *Task) NotifyDone(intentID int64) {
if intentID > 0 {
t.doneMu.Lock()
t.doneIntents = append(t.doneIntents, intentID)
t.doneMu.Unlock()
t.trigMu.Lock()
t.pendingTriggers = append(t.pendingTriggers, agent.TriggerEvent{Kind: "done", IntentID: intentID})
t.trigMu.Unlock()
}
t.Notify()
}
// drainDone returns and clears the intent ids completed since the last round.
func (t *Task) drainDone() []int64 {
t.doneMu.Lock()
defer t.doneMu.Unlock()
ids := t.doneIntents
t.doneIntents = nil
return ids
// NotifyFinding records that a worker reported a finding on intentID (summary),
// then wakes the planner — so the round spells out which intent found what.
func (t *Task) NotifyFinding(intentID int64, summary string) {
t.trigMu.Lock()
t.pendingTriggers = append(t.pendingTriggers, agent.TriggerEvent{Kind: "finding", IntentID: intentID, Detail: summary})
t.trigMu.Unlock()
t.Notify()
}
// drainTriggers returns and clears the trigger events accumulated since the last round.
func (t *Task) drainTriggers() []agent.TriggerEvent {
t.trigMu.Lock()
defer t.trigMu.Unlock()
ev := t.pendingTriggers
t.pendingTriggers = nil
return ev
}