后端: 1. newAgent 运行态重置双保险落地,并补齐写工具后的实时排程预览刷新 - 更新 model/common_state.go:新增 ResetForNextRun,统一清理 round/plan/rough_build/allow_reorder/terminal 等执行期临时状态 - 更新 node/chat.go + service/agentsvc/agent_newagent.go:在“无 pending 且上一轮已 done”时分别于 chat 主入口与 loadOrCreateRuntimeState 冷加载处执行兜底重置,覆盖正常新一轮对话与断线恢复场景 - 更新 model/graph_run_state.go + node/agent_nodes.go + node/execute.go:写工具执行后立即刷新 Redis 排程预览,Deliver 继续保留最终覆盖写,保证前端能及时看到最新操作结果 2. 顺序守卫从“直接中止”改为“优先自动复原 suggested 相对顺序” - 更新 node/order_guard.go:检测到 suggested 顺序被打乱后,不再直接 abort;改为复用当前坑位按 baseline 自动回填,并在复原失败时仅记录诊断日志后继续交付 - 更新 tools/state.go:ScheduleState 新增 RuntimeQueue 运行态快照字段,支持队列化处理与断线恢复 3. 多任务微调工具链升级:新增筛选/队列工具并替换首空位查询口径 - 新建 tools/read_filter_tools.go + tools/runtime_queue.go + tools/queue_tools.go:新增 query_available_slots / query_target_tasks / queue_pop_head / queue_apply_head_move / queue_skip_head / queue_status,支持“先筛选目标,再逐项处理”的稳定微调链路 - 更新 tools/registry.go + tools/write_tools.go + tools/read_helpers.go:移除 find_first_free 注册口径;batch_move 限制为最多 2 条,超过时引导改走队列逐项处理;queue_apply_head_move 纳入写工具集合 4. 复合规划工具扩充,并改为在 newAgent/tools 本地实现以规避循环导入 - 更新 tools/compound_tools.go + tools/registry.go:spread_even 正式接入,并与 min_context_switch 一起作为复合写工具保留在 newAgent/tools 内部实现,不再依赖外层 logic 5. prompt 与工具文档同步升级,明确当前用户诉求锚点与队列化执行约束 - 更新 prompt/execute.go + prompt/execute_context.go + prompt/plan.go:执行提示默认引导 query_target_tasks(enqueue=true) → queue_pop_head → query_available_slots → queue_apply_head_move / queue_skip_head;补齐 batch_move 上限、spread_even 使用边界、顺序策略与工具 JSON 返回示例 - 更新 prompt/execute_context.go:将“初始用户目标”改为“当前用户诉求”,并保留首轮目标来源;旧 observation 折叠文案改为“当前工具调用结果已经被使用过,当前无需使用,为节省上下文空间,已折叠” - 更新 tools/SCHEDULE_TOOLS.md:同步补齐 query_* / queue_* / spread_even / min_context_switch 的说明、限制与返回示例 6. 同步更新调试日志文件 前端:无 仓库:无
425 lines
16 KiB
Go
425 lines
16 KiB
Go
package newagenttools
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
// ==================== 写工具:LLM 通过这些函数修改日程状态 ====================
|
||
// 所有写工具:
|
||
// - 只修改内存中的 ScheduleState,不直接写库
|
||
// - 先校验后修改,校验失败则 state 不变,返回错误信息
|
||
// - 返回自然语言描述变更结果 + 涉及天的占用摘要
|
||
|
||
// MoveRequest 是 BatchMove 的单条移动请求。
|
||
type MoveRequest struct {
|
||
TaskID int `json:"task_id"`
|
||
NewDay int `json:"new_day"`
|
||
NewSlotStart int `json:"new_slot_start"`
|
||
}
|
||
|
||
const (
|
||
// maxBatchMoveSize 是 batch_move 的安全上限。
|
||
//
|
||
// 设计说明:
|
||
// 1. 旧链路中 batch_move 容易因组合冲突导致“整批回滚 + 连续重试”;
|
||
// 2. 先把批量规模限制在 2,作为止血策略,降低一次决策的冲突面;
|
||
// 3. 更大规模的调整应优先走队列化逐项处理(queue_pop_head + queue_apply_head_move)。
|
||
maxBatchMoveSize = 2
|
||
)
|
||
|
||
// ==================== Place ====================
|
||
|
||
// Place 将一个待安排任务预排到指定位置。
|
||
// taskID 必须是真实 pending(无 Slots)状态的任务。
|
||
// 如果目标位置有可嵌入宿主(can_embed=true 且未被嵌入),自动走嵌入逻辑。
|
||
func Place(state *ScheduleState, taskID, day, slotStart int) string {
|
||
// 1. 查找任务。
|
||
task := state.TaskByStateID(taskID)
|
||
if task == nil {
|
||
return fmt.Sprintf("放置失败:任务ID %d 不存在。", taskID)
|
||
}
|
||
|
||
// 2. 校验状态。
|
||
// 2.1 只有“真实 pending”才允许 place;
|
||
// 2.2 suggested / existing 都说明任务已经有落位,继续 place 会破坏当前方案语义;
|
||
// 2.3 旧快照里的 pending+Slots 也会被 IsPendingTask 排除,避免重复补排。
|
||
if !IsPendingTask(*task) {
|
||
return fmt.Sprintf("放置失败:[%d]%s 不是待安排任务,无法放置。", task.StateID, task.Name)
|
||
}
|
||
|
||
// 3. 计算目标范围并校验。
|
||
slotEnd := slotStart + task.Duration - 1
|
||
if err := validateDay(state, day); err != nil {
|
||
return fmt.Sprintf("放置失败:%s", err.Error())
|
||
}
|
||
if err := validateSlotRange(slotStart, slotEnd); err != nil {
|
||
return fmt.Sprintf("放置失败:%s", err.Error())
|
||
}
|
||
|
||
// 4. 冲突检测。
|
||
conflict := findConflict(state, day, slotStart, slotEnd)
|
||
if conflict != nil {
|
||
// 锁定任务的冲突给出特殊提示。
|
||
if conflict.Locked {
|
||
return fmt.Sprintf("放置失败:%s已被 [%d]%s(固定)占用。\n%s\n%s",
|
||
formatDaySlotLabel(state, day, slotStart, slotEnd), conflict.StateID, conflict.Name,
|
||
formatDayOccupancy(state, day), formatFreeHint(state, day))
|
||
}
|
||
return fmt.Sprintf("放置失败:%s已被 [%d]%s 占用。\n%s\n%s",
|
||
formatDaySlotLabel(state, day, slotStart, slotEnd), conflict.StateID, conflict.Name,
|
||
formatDayOccupancy(state, day), formatFreeHint(state, day))
|
||
}
|
||
|
||
// 5. 检查是否有可嵌入宿主。
|
||
host := findEmbedHost(state, day, slotStart, slotEnd)
|
||
|
||
// 6. 执行变更。
|
||
if host != nil {
|
||
// 嵌入路径:设置双向嵌入关系,并把任务提升为 suggested。
|
||
guestID := task.StateID
|
||
hostID := host.StateID
|
||
task.EmbedHost = &hostID
|
||
host.EmbeddedBy = &guestID
|
||
task.Slots = []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotEnd}}
|
||
task.Status = TaskStatusSuggested
|
||
|
||
return fmt.Sprintf("已将 [%d]%s 预排并嵌入到%s(宿主:[%d]%s)。\n%s\n待安排任务剩余:%d个。",
|
||
task.StateID, task.Name, formatDaySlotLabel(state, day, slotStart, slotEnd),
|
||
host.StateID, host.Name,
|
||
formatDayOccupancy(state, day), countPending(state))
|
||
}
|
||
|
||
// 普通路径:直接放置,并标记为 suggested。
|
||
task.Slots = []TaskSlot{{Day: day, SlotStart: slotStart, SlotEnd: slotEnd}}
|
||
task.Status = TaskStatusSuggested
|
||
|
||
return fmt.Sprintf("已将 [%d]%s 预排到%s。\n%s\n待安排任务剩余:%d个。",
|
||
task.StateID, task.Name, formatDaySlotLabel(state, day, slotStart, slotEnd),
|
||
formatDayOccupancy(state, day), countPending(state))
|
||
}
|
||
|
||
// ==================== Move ====================
|
||
|
||
// Move 将一个已落位任务移动到新位置。
|
||
// taskID 仅允许 suggested;existing/pending 都不允许移动。
|
||
func Move(state *ScheduleState, taskID, newDay, newSlotStart int) string {
|
||
// 1. 查找任务。
|
||
task := state.TaskByStateID(taskID)
|
||
if task == nil {
|
||
return fmt.Sprintf("移动失败:任务ID %d 不存在。", taskID)
|
||
}
|
||
|
||
// 2. 校验状态。
|
||
if !IsSuggestedTask(*task) {
|
||
// 2.1 pending 任务尚未落位,应通过 place 安排;
|
||
// 2.2 existing 任务属于已安排事实层,不允许在 execute 微调里直接 move;
|
||
// 2.3 仅 suggested 属于“本轮可微调建议落位”。
|
||
if IsPendingTask(*task) {
|
||
return fmt.Sprintf("移动失败:[%d]%s 当前为待安排状态,请使用 place 放置。", task.StateID, task.Name)
|
||
}
|
||
return fmt.Sprintf("移动失败:[%d]%s 当前为已安排(existing)任务,不允许 move;仅 suggested 任务可移动。", task.StateID, task.Name)
|
||
}
|
||
|
||
// 3. 校验锁定。
|
||
if err := checkLocked(*task); err != nil {
|
||
return fmt.Sprintf("移动失败:%s", err.Error())
|
||
}
|
||
|
||
// 4. 计算新范围。
|
||
duration := taskDuration(*task)
|
||
newSlotEnd := newSlotStart + duration - 1
|
||
|
||
if err := validateDay(state, newDay); err != nil {
|
||
return fmt.Sprintf("移动失败:%s", err.Error())
|
||
}
|
||
if err := validateSlotRange(newSlotStart, newSlotEnd); err != nil {
|
||
return fmt.Sprintf("移动失败:%s", err.Error())
|
||
}
|
||
|
||
// 5. 冲突检测(排除自身)。
|
||
conflict := findConflict(state, newDay, newSlotStart, newSlotEnd, taskID)
|
||
if conflict != nil {
|
||
return fmt.Sprintf("移动失败:%s已被 [%d]%s 占用。\n%s\n%s",
|
||
formatDaySlotLabel(state, newDay, newSlotStart, newSlotEnd), conflict.StateID, conflict.Name,
|
||
formatDayOccupancy(state, newDay), formatFreeHint(state, newDay))
|
||
}
|
||
|
||
// 6. 记录旧位置。
|
||
oldSlots := make([]TaskSlot, len(task.Slots))
|
||
copy(oldSlots, task.Slots)
|
||
oldDesc := formatTaskSlotsBriefWithState(state, oldSlots)
|
||
|
||
// 7. 执行变更。
|
||
task.Slots = []TaskSlot{{Day: newDay, SlotStart: newSlotStart, SlotEnd: newSlotEnd}}
|
||
|
||
// 8. 收集涉及的天(去重)。
|
||
affectedDays := collectAffectedDays(oldSlots, task.Slots)
|
||
|
||
var sb strings.Builder
|
||
sb.WriteString(fmt.Sprintf("已将 [%d]%s 从%s移至%s。\n",
|
||
task.StateID, task.Name, oldDesc, formatDaySlotLabel(state, newDay, newSlotStart, newSlotEnd)))
|
||
for _, d := range affectedDays {
|
||
sb.WriteString(formatDayOccupancy(state, d) + "\n")
|
||
}
|
||
return sb.String()
|
||
}
|
||
|
||
// ==================== Swap ====================
|
||
|
||
// Swap 交换两个已落位任务的位置。
|
||
// 两个任务都必须是 suggested / existing、非锁定、总时长相同。
|
||
func Swap(state *ScheduleState, taskAID, taskBID int) string {
|
||
// 1. 查找两个任务。
|
||
taskA := state.TaskByStateID(taskAID)
|
||
if taskA == nil {
|
||
return fmt.Sprintf("交换失败:任务ID %d 不存在。", taskAID)
|
||
}
|
||
taskB := state.TaskByStateID(taskBID)
|
||
if taskB == nil {
|
||
return fmt.Sprintf("交换失败:任务ID %d 不存在。", taskBID)
|
||
}
|
||
|
||
if taskAID == taskBID {
|
||
return "交换失败:不能与自己交换。"
|
||
}
|
||
|
||
// 2. 校验状态。
|
||
if !IsPlacedTask(*taskA) {
|
||
return fmt.Sprintf("交换失败:[%d]%s 不是已落位任务。", taskA.StateID, taskA.Name)
|
||
}
|
||
if !IsPlacedTask(*taskB) {
|
||
return fmt.Sprintf("交换失败:[%d]%s 不是已落位任务。", taskB.StateID, taskB.Name)
|
||
}
|
||
|
||
// 3. 校验锁定。
|
||
if err := checkLocked(*taskA); err != nil {
|
||
return fmt.Sprintf("交换失败:%s", err.Error())
|
||
}
|
||
if err := checkLocked(*taskB); err != nil {
|
||
return fmt.Sprintf("交换失败:%s", err.Error())
|
||
}
|
||
|
||
// 4. 校验时长。
|
||
durA := taskDuration(*taskA)
|
||
durB := taskDuration(*taskB)
|
||
if durA != durB {
|
||
return fmt.Sprintf("交换失败:[%d]%s 占%d个时段,[%d]%s 占%d个时段,时长不同无法直接交换。",
|
||
taskA.StateID, taskA.Name, durA, taskB.StateID, taskB.Name, durB)
|
||
}
|
||
|
||
// 5. 记录旧位置。
|
||
oldSlotsA := make([]TaskSlot, len(taskA.Slots))
|
||
copy(oldSlotsA, taskA.Slots)
|
||
oldSlotsB := make([]TaskSlot, len(taskB.Slots))
|
||
copy(oldSlotsB, taskB.Slots)
|
||
|
||
// 6. 交换 Slots。
|
||
taskA.Slots, taskB.Slots = taskB.Slots, taskA.Slots
|
||
|
||
// 7. 交换后冲突检测:A 的新位置(原 B 的位置)是否有第三方冲突。
|
||
// 需要排除 B(因为 B 现在在 A 的旧位置,已经被 swap 了)。
|
||
for _, slot := range taskA.Slots {
|
||
conflict := findConflict(state, slot.Day, slot.SlotStart, slot.SlotEnd, taskAID, taskBID)
|
||
if conflict != nil {
|
||
// 回滚
|
||
taskA.Slots = oldSlotsA
|
||
taskB.Slots = oldSlotsB
|
||
return fmt.Sprintf("交换失败:[%d]%s 的新位置%s与 [%d]%s 冲突。",
|
||
taskA.StateID, taskA.Name, formatDaySlotLabel(state, slot.Day, slot.SlotStart, slot.SlotEnd),
|
||
conflict.StateID, conflict.Name)
|
||
}
|
||
}
|
||
for _, slot := range taskB.Slots {
|
||
conflict := findConflict(state, slot.Day, slot.SlotStart, slot.SlotEnd, taskAID, taskBID)
|
||
if conflict != nil {
|
||
// 回滚
|
||
taskA.Slots = oldSlotsA
|
||
taskB.Slots = oldSlotsB
|
||
return fmt.Sprintf("交换失败:[%d]%s 的新位置%s与 [%d]%s 冲突。",
|
||
taskB.StateID, taskB.Name, formatDaySlotLabel(state, slot.Day, slot.SlotStart, slot.SlotEnd),
|
||
conflict.StateID, conflict.Name)
|
||
}
|
||
}
|
||
|
||
// 8. 成功输出。
|
||
affectedDays := collectAffectedDays(oldSlotsA, taskA.Slots)
|
||
affectedDays = append(affectedDays, collectAffectedDays(oldSlotsB, taskB.Slots)...)
|
||
affectedDays = uniqueSorted(affectedDays)
|
||
|
||
var sb strings.Builder
|
||
sb.WriteString("交换完成:\n")
|
||
sb.WriteString(fmt.Sprintf(" [%d]%s:%s → %s\n",
|
||
taskA.StateID, taskA.Name,
|
||
formatTaskSlotsBriefWithState(state, oldSlotsA), formatTaskSlotsBriefWithState(state, taskA.Slots)))
|
||
sb.WriteString(fmt.Sprintf(" [%d]%s:%s → %s\n",
|
||
taskB.StateID, taskB.Name,
|
||
formatTaskSlotsBriefWithState(state, oldSlotsB), formatTaskSlotsBriefWithState(state, taskB.Slots)))
|
||
for _, d := range affectedDays {
|
||
sb.WriteString(formatDayOccupancy(state, d) + "\n")
|
||
}
|
||
return sb.String()
|
||
}
|
||
|
||
// ==================== BatchMove ====================
|
||
|
||
// BatchMove 原子性地批量移动多个任务。
|
||
// moves 中每个 task_id 都必须是 suggested;existing/pending 任一命中都会整批失败。
|
||
// 全部成功才生效,任一失败则完全回滚。
|
||
func BatchMove(state *ScheduleState, moves []MoveRequest) string {
|
||
if len(moves) == 0 {
|
||
return "批量移动失败:移动列表为空。"
|
||
}
|
||
if len(moves) > maxBatchMoveSize {
|
||
return fmt.Sprintf("批量移动失败:当前最多支持 %d 条移动请求。请改用队列化逐项处理(queue_pop_head + queue_apply_head_move)。", maxBatchMoveSize)
|
||
}
|
||
|
||
// 1. 全量校验阶段(不改 state)。
|
||
for i, m := range moves {
|
||
task := state.TaskByStateID(m.TaskID)
|
||
if task == nil {
|
||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n任务ID %d 不存在(第%d条移动请求)。", m.TaskID, i+1)
|
||
}
|
||
if !IsSuggestedTask(*task) {
|
||
// 1.1 保持与 Move 一致:批量移动仅允许 suggested;
|
||
// 1.2 pending / existing 任一命中都应整批失败并回滚。
|
||
if IsPendingTask(*task) {
|
||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n[%d]%s 当前为待安排状态,请使用 place(第%d条移动请求)。",
|
||
task.StateID, task.Name, i+1)
|
||
}
|
||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n[%d]%s 当前为已安排(existing)任务,不允许 move;仅 suggested 任务可移动(第%d条移动请求)。",
|
||
task.StateID, task.Name, i+1)
|
||
}
|
||
if err := checkLocked(*task); err != nil {
|
||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n%s(第%d条移动请求)", err.Error(), i+1)
|
||
}
|
||
|
||
duration := taskDuration(*task)
|
||
newSlotEnd := m.NewSlotStart + duration - 1
|
||
if err := validateDay(state, m.NewDay); err != nil {
|
||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n%s(第%d条移动请求)", err.Error(), i+1)
|
||
}
|
||
if err := validateSlotRange(m.NewSlotStart, newSlotEnd); err != nil {
|
||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n%s(第%d条移动请求)", err.Error(), i+1)
|
||
}
|
||
}
|
||
|
||
// 2. 克隆 state,在克隆上执行。
|
||
clone := state.Clone()
|
||
|
||
// 收集涉及的天。
|
||
affectedDays := make(map[int]bool)
|
||
|
||
// 3. 逐个应用 + 冲突检测。
|
||
for _, m := range moves {
|
||
task := clone.TaskByStateID(m.TaskID)
|
||
duration := taskDuration(*task)
|
||
newSlotEnd := m.NewSlotStart + duration - 1
|
||
|
||
// 记录旧位置涉及的天。
|
||
for _, slot := range task.Slots {
|
||
affectedDays[slot.Day] = true
|
||
}
|
||
|
||
// 冲突检测(在 clone 的中间状态上,排除自身)。
|
||
conflict := findConflict(clone, m.NewDay, m.NewSlotStart, newSlotEnd, m.TaskID)
|
||
if conflict != nil {
|
||
return fmt.Sprintf("批量移动失败,全部回滚,无任何变更。\n冲突:[%d]%s → %s,该位置已被 [%d]%s 占用。",
|
||
task.StateID, task.Name, formatDaySlotLabel(state, m.NewDay, m.NewSlotStart, newSlotEnd),
|
||
conflict.StateID, conflict.Name)
|
||
}
|
||
|
||
// 应用移动。
|
||
task.Slots = []TaskSlot{{Day: m.NewDay, SlotStart: m.NewSlotStart, SlotEnd: newSlotEnd}}
|
||
affectedDays[m.NewDay] = true
|
||
}
|
||
|
||
// 4. 全部成功,将 clone 的数据写回原 state。
|
||
state.Tasks = clone.Tasks
|
||
|
||
// 5. 输出结果。
|
||
days := sortedKeys(affectedDays)
|
||
var sb strings.Builder
|
||
sb.WriteString(fmt.Sprintf("批量移动完成,%d个任务全部成功:\n", len(moves)))
|
||
for _, m := range moves {
|
||
task := state.TaskByStateID(m.TaskID)
|
||
duration := taskDuration(*task)
|
||
sb.WriteString(fmt.Sprintf(" [%d]%s → %s\n",
|
||
task.StateID, task.Name,
|
||
formatDaySlotLabel(state, m.NewDay, m.NewSlotStart, m.NewSlotStart+duration-1)))
|
||
}
|
||
for _, d := range days {
|
||
sb.WriteString(formatDayOccupancy(state, d) + "\n")
|
||
}
|
||
return sb.String()
|
||
}
|
||
|
||
// ==================== Unplace ====================
|
||
|
||
// Unplace 将一个已落位任务移除,恢复为待安排状态。
|
||
// taskID 允许是 suggested / existing,但不能是真实 pending。
|
||
// 如果任务有嵌入关系,会自动清理双向指针。
|
||
func Unplace(state *ScheduleState, taskID int) string {
|
||
// 1. 查找任务。
|
||
task := state.TaskByStateID(taskID)
|
||
if task == nil {
|
||
return fmt.Sprintf("移除失败:任务ID %d 不存在。", taskID)
|
||
}
|
||
|
||
// 2. 校验状态。
|
||
if IsPendingTask(*task) {
|
||
return fmt.Sprintf("移除失败:[%d]%s 已经是待安排状态。", task.StateID, task.Name)
|
||
}
|
||
|
||
// 3. 校验锁定。
|
||
if err := checkLocked(*task); err != nil {
|
||
return fmt.Sprintf("移除失败:%s", err.Error())
|
||
}
|
||
|
||
// 4. 记录旧位置。
|
||
oldSlots := make([]TaskSlot, len(task.Slots))
|
||
copy(oldSlots, task.Slots)
|
||
oldDesc := formatTaskSlotsBriefWithState(state, oldSlots)
|
||
|
||
// 5. 清理嵌入关系。
|
||
// 如果该任务嵌入到了某个宿主上,清除宿主的 EmbeddedBy。
|
||
if task.EmbedHost != nil {
|
||
host := state.TaskByStateID(*task.EmbedHost)
|
||
if host != nil {
|
||
host.EmbeddedBy = nil
|
||
}
|
||
task.EmbedHost = nil
|
||
}
|
||
// 如果该任务是一个宿主且有嵌入客人,将客人也恢复为 pending。
|
||
if task.EmbeddedBy != nil {
|
||
guest := state.TaskByStateID(*task.EmbeddedBy)
|
||
if guest != nil {
|
||
// 先从嵌入时设置的 Slots 推算 Duration,再清空。
|
||
// Place 嵌入时 guest.Slots 被设置为实际占用范围,这里从中恢复时长。
|
||
if len(guest.Slots) > 0 {
|
||
guest.Duration = taskDuration(*guest)
|
||
}
|
||
guest.EmbedHost = nil
|
||
guest.Slots = nil
|
||
guest.Status = TaskStatusPending
|
||
}
|
||
task.EmbeddedBy = nil
|
||
}
|
||
|
||
// 6. 执行变更。
|
||
task.Slots = nil
|
||
task.Status = TaskStatusPending
|
||
|
||
// 7. 收集涉及的天。
|
||
affectedDays := collectAffectedDaysFromSlots(oldSlots)
|
||
|
||
var sb strings.Builder
|
||
sb.WriteString(fmt.Sprintf("已将 [%d]%s 从%s移除,恢复为待安排状态。\n",
|
||
task.StateID, task.Name, oldDesc))
|
||
for _, d := range affectedDays {
|
||
sb.WriteString(formatDayOccupancy(state, d) + "\n")
|
||
}
|
||
sb.WriteString(fmt.Sprintf("待安排任务剩余:%d个。", countPending(state)))
|
||
return sb.String()
|
||
}
|