练习 28:bash 重构——后台任务

到上一章为止,bash 是同步的:命令跑完(或者超时被杀),这一轮才能往下 走。跑一个测试套件、起一个开发服务器,都会把整个 agent 卡死在那儿干等 ——上一章的 goal 能让它连轴转好几轮,可只要有一条命令要五分钟,五分钟 里它什么都做不了。

这一章把 bash 拆成两条路:同步照旧,后台新开。run_in_background 参数一给,命令立刻放到后台去跑,轮次不等它。后台任务有两种性格: 一次性的(测试、构建——跑完就完,完成了系统自动通知)和常驻的 (服务、REPL——一直活着,模型可以看它的输出、往它的 stdin 喂东西)。 为这两种性格,这一章还带来两个新工具 terminal_output / terminal_input,和一个专治模型轮询强迫症的设计。

还有两张旧字据要在这一章兑现。练习 23 写沙箱的时候立过一句:"以后 任何新的执行路径都必须从 shellCommand 这扇门走。"练习 27 的备注里 说零进度刹车"防的是空转"。这一章你会看到,这两句话说的是同一族问题。

敲进去

在练习 27 的代码上继续写。

先给 bash 的声明加一个参数。注意它是枚举,不是布尔:

				"run_in_background": map[string]any{
					"type": "string",
					"enum": []string{"async", "interactive"},
					"description": "可选。\"async\" = 一次性任务放后台,完成自动通知,不许轮询;" +
						"\"interactive\" = 常驻服务/REPL 放后台,可看输出可喂输入。不给 = 同步执行。",
				},

"要不要放后台"本来是个是非题,为什么不用布尔?因为真正的问题不是 "放不放",是放到后台之后这个进程归谁管。一次性任务归系统管——跑完 推送通知,中途不许打扰;常驻服务归模型管——什么时候看、喂什么进去, 它自己决定。两种管法接下来的代码处处不同,从参数这一层就分开,比事后 猜"模型把测试放后台是想轮询还是想等通知"可靠得多。

execute 里开岔路。注意它开在哪一层的后面

	// 后台的岔路开在这里——注册表层的权限门禁这时已经过完了(练习 9 的门
	// 装在分发那一层,跟命令最终走哪条执行路径无关),所以后台命令和同步
	// 命令过的是同一道门:deny 照样拦,ask 照样问。
	if in.RunBg != "" {
		mode := bgMode(in.RunBg)
		if mode != bgAsync && mode != bgInteractive {
			return fmt.Sprintf("错误: run_in_background 只能是 \"async\" 或 \"interactive\"(收到 %q)。"+
				"一次性任务用 async,常驻服务/REPL 用 interactive,要同步执行就别传这个参数", in.RunBg)
		}
		if mgr := bgFrom(ctx); mgr != nil {
			id, err := mgr.start(in.Command, mode)
			// ……返回 id 和使用提示,从略……
		}
		// 走到这里说明在子 agent 里(runChildLoop 抹掉了宿主):不报错,
		// 塌回同步执行——octo 的选择也是这样。子 agent 没有"以后再收
		// 结果"的以后,但任务本身还是要完成的,降级比拒绝有用。
	}

然后是后台进程本身。它的启动函数里藏着这一章最重要的两个决定:

// start 把命令放到后台跑,立刻返回 id。
//
// 命令走的还是 shellCommand——练习 23 立过字据:"以后任何新的执行路径
// 都必须从这扇门走。"这一章就是那句话说的"以后":后台进程自动继承沙箱,
// 一行沙箱代码都不用碰。
//
// ctx 刻意用 context.Background() 另起:后台进程的命不能拴在这一轮的
// ctx 上。练习 24 花了整章让打断穿透到每个工具,这里是那条规则的第一个
// 例外——你按 Ctrl+C 是说"这一轮别做了",不是"把我特意放到后台的服务
// 也杀掉"。要杀后台进程,得有一个明确说这件事的动作(本书里是退出 REPL
// 时收编所有后台进程;octo 还有单杀的 kill_shell 工具)。
func (m *bgManager) start(command string, mode bgMode) (string, error) {
	ctx, cancel := context.WithCancel(context.Background())
	cmd := shellCommand(ctx, command)
	cmd.Dir = workDir
	// 后台进程自成一个进程组。不这么做,杀的时候只能杀到最外层的
	// sh -c 包装,它 fork 出来的活儿(sleep、服务进程)会变成孤儿
	// 接着跑——"杀掉了"就成了假话。octo 把这条写成硬规矩:"永远
	// 杀整个进程组,绝不只杀直接子进程。"
	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}

	pr, pw := io.Pipe()
	cmd.Stdout, cmd.Stderr = pw, pw
	stdinR, stdinW, err := os.Pipe()
	// ……启动与登记,从略……

启动之后是两个 goroutine,它们之间有一个不能错的顺序:

	// 读者:把合并的输出一行行搬进缓冲。
	readerDone := make(chan struct{})
	go func() {
		defer close(readerDone)
		scanner := bufio.NewScanner(pr)
		scanner.Buffer(make([]byte, 64*1024), maxBgOutputBytes)
		for scanner.Scan() {
			p.append(append(scanner.Bytes(), '\n'))
		}
	}()
	// 收尾的:等进程退出,关掉管道让读者看到 EOF,再等读者把管道排干,
	// 然后才能发完成通知——顺序错了,跑得快的进程会把尾巴输出弄丢:
	// 通知发出去的时候读者还没搬完。
	go func() {
		err := cmd.Wait()
		pw.Close()
		stdinW.Close()
		<-readerDone
		p.finish(err)
		m.done <- formatBgNote(p)
	}()

输出存在一个有上限的缓冲里,两种读法对应两种用途:

// readNew 返回上次取走之后的新输出并推进游标——完成通知用它,保证通知里
// 不重复报模型已经看过的内容。
func (p *bgProc) readNew() (string, string) { /* ……见仓库…… */ }

// tailLines 返回最近 n 行的快照(n <= 0 = 全部保留的),不动游标——
// 重复调用看到同一个视图,这本身就取消了"多读几次能多看到点什么"的
// 轮询动机。防轮询计数在这里:还在跑 + 快照是空的,才算一次空轮询。
func (p *bgProc) tailLines(n int) (output, status string, blocked bool) {
	// ……取尾部若干行,从略……
	if !p.done && out == "" {
		now := time.Now()
		if p.emptyPollCount == 0 || now.Sub(p.firstEmptyPoll) > pollWindow {
			p.firstEmptyPoll = now // 开一个新窗口
			p.emptyPollCount = 1
		} else {
			p.emptyPollCount++
			if p.emptyPollCount >= maxEmptyPolls {
				blocked = true
			}
		}
	} else {
		p.emptyPollCount = 0
		p.firstEmptyPoll = time.Time{}
	}
	return out, p.statusLocked(), blocked
}

防轮询的参数是一个窗口,不是一个简单的计数:

// 防轮询窗口:30 秒内对一个还在跑的进程空读 3 次,就判定为轮询,硬停。
// 窗口而不是简单计数,是给常驻服务留的活口——隔几分钟看一眼日志是正常
// 检查,30 秒内连问三次"好了没"才是强迫症。数值照抄 octo。
const pollWindow = 30 * time.Second
const maxEmptyPolls = 3

完成通知的包装和去处,你已经很熟了:

// formatBgNote 把一次后台完成包成环境提醒,跟练习 26 到点那句话同一个
// 包装:模型该把它当事件处理,界面上也不该长出一句假的用户发言。
func formatBgNote(p *bgProc) string {
	out, status := p.readNew()
	var b strings.Builder
	b.WriteString("<system-reminder>\n[后台任务完成]\n")
	fmt.Fprintf(&b, "后台进程 %s(`%s`)%s。", p.id, p.command, status)
	// ……新输出,从略……
	// 跑得太快的 async 任务,教育一句。教育放在完成通知里而不是文档里,
	// 因为这一刻模型手上就攥着证据:它刚为一条几秒钟的命令多花了一轮。
	if p.mode == bgAsync {
		if d := time.Since(p.start).Round(100 * time.Millisecond); d < shortAsyncDuration {
			fmt.Fprintf(&b, "\n\n[注意:这个任务 %s 就跑完了——这么快的命令根本不需要放后台。"+
				"同步调用(不传 run_in_background)会在同一轮直接返回同样的输出,不用记 id,"+
				"也不用多花一轮等这条通知。只把确有把握会跑很久的命令放后台。]", d)
		}
	}
	b.WriteString("\n</system-reminder>")
	return b.String()
}

通知去哪?m.done 是一个 channel,主循环的两个 select 各加一个 case ——跟练习 26 闹钟到点的两条路一模一样。空闲时,通知本身就是下一轮的 输入:

		case note := <-theBg.done:
			// 空闲时后台任务跑完了:跟闹钟到点一个待遇,通知本身就是
			// 下一轮的输入,模型自己决定拿结果做什么。
			fmt.Fprintln(os.Stderr, "\n[后台任务完成,自动开始新的一轮]")
			return note, false

轮次跑着时,折成插话:

		case note := <-theBg.done:
			// 后台任务在轮次中间跑完了:同一个待遇,折成插话。这是这个
			// select 的第四种事件来源,主循环的形状还是没变。
			box.enqueue(note, false)
			fmt.Fprintln(os.Stderr, "[后台任务完成,这一轮还没跑完,当插话塞进去]")

两个新工具里,terminal_output 是快照 + 防轮询的出口:

	out, status, blocked := p.tailLines(lines)
	header := "[状态: " + status + "]"
	if out == "" {
		msg := header + "\n(还没有输出)"
		if blocked {
			// 防轮询的硬停:不是建议,是直接把话挑明。轮询烧的是真金白银
			// ——每一次空查都是一整次带全部上下文的请求。
			msg += "\n\n[停:30 秒内第三次空查了。不要再查这个进程," +
				"有新输出之前查多少次都是空的。先做别的事,或者结束这一轮。]"
		}
		return msg
	}

terminal_input 往进程的 stdin 原样写字节;async 任务两个工具都不伺候 ——不许看(等通知)也不许喂(一次性任务要什么输入)。收尾两件小事: 退出 REPL 时 theBg.killAll() 收编所有后台进程,不留孤儿;子 agent 进门前 ctx = withBg(ctx, nil) 把宿主抹掉——bash 是同一个工具, run_in_background 参数它看得见,但完成通知回不去一个只活一次调用的 循环,所以后台请求在子 agent 里静默塌回同步执行。

完整代码见仓库 exercises/ex28/

跑起来

cd exercises/ex28
export OPENAI_API_KEY=sk-xxxx
export MODEL=deepseek-v4-flash
export OPENAI_BASE_URL=https://api.deepseek.com/v1
go run .

先准备一个慢脚本当陪练:

printf 'sleep 20\necho 编译完成\n' > slow.sh

然后跟模型说:

用 bash 把 sh slow.sh 以 async 模式放到后台跑(这个脚本大概要 20 秒),
然后在 notes.txt 里写一行「后台任务等待中」,这一轮就做到这里。

你应该看到什么

实验一:放到后台,这一轮不等它

DeepSeek(转写有排版精简):

> 用 bash 把 sh slow.sh 以 async 模式放到后台跑(这个脚本大概要 20 秒),
  然后在 notes.txt 里写一行「后台任务等待中」,这一轮就做到这里。
[round 1] bash({"command": "sh slow.sh", "run_in_background": "async"})
⚠️  模型想执行: sh slow.sh
允许吗?(y/N) y
[round 2] write_file({"path": "notes.txt", "content": "后台任务等待中\n"})
完成了:sh slow.sh 已放入后台(id=bg_1,async 模式),预计 20 秒跑完,
完成后系统会自动通知,这一轮我不去轮询它。notes.txt 已写入。

>
[后台任务完成,自动开始新的一轮]
[round 2] write_file({"content": "后台任务已完成\n", "path": "notes.txt"})
后台任务 bg_1(sh slow.sh)已跑完,退出码 0,输出:编译完成
notes.txt 也已更新为「后台任务已完成」,和实际状态保持一致。

三件事同时成立了:20 秒的命令没有卡住轮次(同一轮里模型接着写了 notes.txt);轮次收工后进程安静回到提示符;20 秒后完成通知自动开了 新一轮,模型拿着结果继续干活——没人碰过键盘。注意批准那一问:后台 命令过的还是练习 9 那道门。

实验二:跑得太快的,会被教育

把一条秒完的命令硬塞进后台:

> 用 bash 以 async 模式在后台跑 echo hi——我知道它很快,就是想看看后台模式怎么工作。
[round 1] bash({"command": "echo hi", "run_in_background": "async"})
[后台任务完成,这一轮还没跑完,当插话塞进去]
已经用 async 模式把 echo hi 放到后台了……等系统完成通知到了我再告诉你结果。
[插话来晚了:这一轮已经收工,把 1 条折成一次跟进的对话]
后台任务 bg_1 跑完了,退出码 0,输出就是 hi。
顺便说一句系统提醒里那条建议很实在:echo hi 这种瞬间完成的命令,其实
直接同步调用更合适——同一轮就能拿到输出,不用记 id、不用多等一轮通知。

这条转写值得多看两眼。命令快到什么程度?模型宣布"放入后台"的那句话 还没说完,完成通知就到了——走的是插话通道("这一轮还没跑完,当插话 塞进去");而模型这一轮收工太快,插话没赶上取用点,又被练习 25 的 "来晚了"兜底接住,折成一次跟进对话。两条三章前建好的管道,接住了一个 今天才出现的事件。最后,通知里那句"这么快的命令根本不需要放后台"的 教育,模型原样消化并对用户转述了。

本机 Ollama(qwen3:4b-instruct)跑同样的实验,链路一致,转述得还更 一板一眼:"除非你明确知道某命令会持续很长时间,否则不应将轻量命令 放后台。"

实验三:常驻的服务,可看可喂

陪练是一个回声服务:

printf 'while read line; do echo "回声: $line"; done\n' > echoloop.sh
> 用 bash 以 interactive 模式在后台跑 sh echoloop.sh(一个回声服务),
  然后用 terminal_input 给它发一句「你好后台」(记得带换行符),
  再用 terminal_output 看它的回声,确认正常后汇报。
[round 2] bash({"command": "sh echoloop.sh", "run_in_background": "interactive"})
⚠️  模型想执行: sh echoloop.sh
允许吗?(y/N) y
[round 3] terminal_input({"id": "bg_1", "input": "你好后台\n"})
[round 4] terminal_output({"id": "bg_1"})
回声确认正常 ✅
- 后台进程 bg_1(sh echoloop.sh)已以 interactive 模式启动,状态 running
- 通过 terminal_input 发送了「你好后台」(带换行)
- terminal_output 返回 `回声: 你好后台`,和输入一致
整个链路通了:stdin 写入 → 脚本 read 循环 → stdout 回声。

一个常驻进程,模型隔着两个工具跟它对话。后来我们让它把服务收掉,它 干了一件没人教过的事:

[round 1] terminal_input({"id": "bg_1", "input": "\u0004"})

\u0004 是 Ctrl-D——它往 stdin 发了一个 EOF,让 read 循环自然退出。 体面的关法,不是一刀 kill。

实验四:防轮询——第三次空查就翻脸

陪练换成一个永远没输出的进程(sleep 300),然后明说要连查三次:

> 用 bash 以 interactive 模式在后台跑 sh silent.sh,然后立刻连续查三次
  它的输出——就是要连续查,我想看看会发生什么。
[round 4] terminal_output({"id": "bg_1", "lines": 50})
[round 4] terminal_output({"id": "bg_1", "lines": 50})
[round 4] terminal_output({"id": "bg_1", "lines": 50})
实验结果出来了,很明确:
1. 第一次 → [状态: running](还没有输出)
2. 第二次 → [状态: running](还没有输出)
3. 第三次 → 触发硬停,直接被拦下来了:
> [停:30 秒内第三次空查了。不要再查这个进程,有新输出之前查多少次
  都是空的。先做别的事,或者结束这一轮。]
之后这个进程的输出我不能再主动查了……进程本身不受影响:bg_1 还在正常
sleep 300,5 分钟后自己结束。

模型一口气把三个 terminal_output 塞进了同一轮(练习 20 的并发扇出 接住了它们),第三个撞上硬停。它的复述完全准确——包括"进程本身不受 影响":防轮询停的是查询,不是进程。

实验五:练习 23 的字据,今天兑现

-sandbox 模式启动(工作目录可写、家目录不可写),让模型把一条越界 的命令放到后台:

> 用 bash 以 async 模式在后台跑 touch /Users/roy.lei/ex28-sandbox-proof.txt,
  完成后告诉我结果。
[round 1] bash({"command": "touch /Users/roy.lei/ex28-sandbox-proof.txt", "run_in_background": "async"})
⚠️  模型想执行: touch /Users/roy.lei/ex28-sandbox-proof.txt
允许吗?(y/N) y
[插话来晚了:这一轮已经收工,把 1 条折成一次跟进的对话]
结果:失败。
- 后台进程 bg_1 已退出,exit status 1
- 错误信息:touch: /Users/roy.lei/ex28-sandbox-proof.txt: Operation not permitted
也就是说,touch 在这个路径下没有权限创建文件,文件没有生成。

注意顺序:权限系统放行了(用户按了 y),沙箱拦下了(OS 返回 Operation not permitted)。练习 23 说"批准了也越不出去",那时候验的 是同步命令;这一章的后台进程从没写过一行沙箱代码,走到 shellCommand 那扇门时自动进了同一个笼子。字据兑现。

顺带一提,模型接下来自己做了组对照:/tmp 不可写(还记得练习 23 的坑 吗——沙箱放行的是 $TMPDIR,不是 /tmp)、工作目录可写。它总结:"以后 需要落地文件就用工作目录。"边界摸清了,一次没越过去。

发生了什么

一个参数,两种性格

run_in_background 是枚举不是布尔。"要不要放后台"看起来是个是非题, 但真正的问题是放到后台之后这个进程归谁管:async 归系统管——跑完 推送通知,中途不许打扰;interactive 归模型管——什么时候看、喂什么 进去,它自己决定。接下来的每一段代码都要区分这两种管法(通知只推 async 的完成?不对,两种都推;轮询只拦……),从参数这一层就把话说清, 比事后猜"模型把测试放后台是想轮询还是想等通知"可靠得多。

octo 的这个参数最早就是布尔,2026 年 6 月改成枚举,提交里标着 BREAKING CHANGE——理由就是上面这段:布尔说不清进程归谁管, terminal_output / terminal_input 也就没法只对该开放的进程开放。

第二条执行路,过的还是同一些门

这一章新开的路上,一道新门都没装:

  • 权限门禁:装在注册表的分发层(练习 9),命令还没碰到执行路径就 已经过完了 deny/ask 的检查——实验一里那个批准提问就是证据。
  • 沙箱:装在 shellCommand 这扇门上(练习 23),后台进程照样从 这里走,实验五里 OS 会替我们证明这一点。

门装在路口之前的公共地带,加一条新路不用重新装门——这就是练习 23 立字据时说"笼子只有装在唯一的门上才算数"的原因。当时那句话是防御性 的(防以后有人绕开),今天它变成了生产力:这一章的后台执行一行沙箱 代码、一行权限代码都没写。

打断穿透的第一个例外

练习 24 花了一整章让 Ctrl+C 穿透到每个工具的最深处,这一章第一次 反着来:后台进程的 ctx 用 context.Background() 另起,轮次的取消 够不着它。理由值得记住:你按 Ctrl+C 是说"这一轮别做了",不是"把我 特意放到后台的服务也杀掉"。生命周期跟着意图走,不是跟着技术上的 从属关系走。

但"不跟轮次死"不等于"不死"。两条命都有明确的主人:退出 REPL 时 killAll 收编全部后台进程,不留孤儿。而"杀"这个动作本身也有讲究—— 信号发给整个进程组(负的 pgid),不是只发给直接子进程:

func (p *bgProc) kill() {
	if p.pgid > 0 {
		_ = syscall.Kill(-p.pgid, syscall.SIGKILL)
	}
	p.cancel()
}

直接子进程只是 sh -c 那层包装。只杀它,sh slow.sh 里的 sleep 会 变成孤儿接着跑——"杀掉了"就成了假话。octo 把这条写成硬规矩,所有 终止路径共用一个函数,就是防这两条规则在不同调用点悄悄走样。

推与拉,各管一种性格

完成是的:进程一退出,收尾的那个 goroutine 把新输出包成通知送出去。读的是 readNew——一个会推进的游标,保证通知里不重复报模型已经看过的内容。

进度是的,而且是快照:terminal_outputtailLines,不动 任何游标,重复调用看到同一个视图。这个设计本身就在拆轮询的台:既然 多查一次不会多看到任何东西,"再查一次说不定有新的"这个动机就不成立 了。防轮询窗口拦的是剩下的顽固分子:30 秒内对一个还在跑的进程空查 三次,工具直接翻脸——不是建议,是把话挑明:"有新输出之前查多少次都 是空的。"

这和练习 27 的零进度刹车是同族问题:模型自己驱动自己的循环,永远 需要一个不靠模型自觉的止损。刹车管的是"续了一轮却没进展",防轮询管 的是"查了三次却没输出"——判据都是机械的,都不跟模型商量。

最后一道是教育:跑得太快的 async 任务,完成通知里附一句"你根本不需要 后台"。教育放在通知里而不是文档里,因为这一刻模型手上就攥着证据—— 它刚为一条几秒钟的命令多花了一轮。

第四种事件,主循环还是那个形状

完成通知的两条去路,你在练习 26 已经走过一遍:空闲时直接开一轮 (waitIdle 的一个 case),轮次跑着时折成插话(runInterruptible 的 一个 case)。练习 24 立骨架时说"每加一种要处理的事就多一个 case", 键盘、要问人、闹钟、后台完成——四次兑现,select 还是那个 select。

有一个跟 octo 的分歧要交代:octo 空闲时收到完成通知不会自动开 一轮——通知挂在界面的后台面板上,等用户下一次开口才带给模型。它有 TUI 面板,人看得见"有个任务完成了";本书的极简 REPL 没有面板,通知 要是躺在收件箱里等人开口,"完成自动通知"就打了折扣。前提不同,取舍 就不同——这不是谁对谁错,是同一个设计问题在两种界面下的两个答案。

常见问题

模型把参数写错了怎么办?这一章真机撞上两次。DeepSeek 写出过 "run_in_background": async(裸字面量,整段 JSON 直接非法)——工具层 报参数错,模型下一轮自己修好了。顺带修掉一个从练习 9 潜伏至今的洞: 参数烂掉时 commandOf 拿不出命令,权限层曾经会拿着空命令去问 "允许吗?"——用户看着一片空白没法判断。现在参数烂的调用直接放过 权限门,让工具自己报错,反正什么都不会执行。教训跟 update_goal 的 enum 校验同款:说明书拦不住手滑,每一层都要自己再验一遍

模型想等后台任务,自己跑 sleep 30 怎么办?sleep 是同步命令, 照样占轮次——等于把"不阻塞"又变回了"阻塞"。正确的等法是结束这一轮: 完成通知会把模型叫回来。bash 的工具描述里"先做别的事,或者结束这一轮" 说的就是这个。这跟练习 26 的闹钟是同一个观念转换:等待不是一个动作, 是把控制权交回主循环。

**interactive 进程的输出,为什么有时候一直是空的?**八成是缓冲。 程序的 stdout 接的是管道不是终端,C 标准库会把行缓冲换成全缓冲—— 日志都攒在程序自己的缓冲区里没吐出来。octo 在空输出的提示里直接教 解法:用 stdbuf -oL <cmd> 强制行缓冲再启动(macOS 自带的 sh 内建 echo 不经过 stdio,所以本章实验的回声服务没踩这个坑)。

octo 的后台家族还有什么没搬?kill_shell(单杀一个进程,信号可选, 见加分练习);detached:true(真正的守护进程:setsid 自立门户,故意 活过 octo 本体,不被追踪也不被收编);同步命令超时自动转后台(octo 的 同步执行本来就是一个隐藏的后台进程,人可以按 Ctrl+B 把它提前"转正"); 完成通知里附"还有 N 个任务在跑"的摘要;超长输出溢出到临时文件。 骨架都在,那些是肉。

加分练习

  1. kill_shell 工具。给模型一把单杀的刀:按 id 终止一个后台进程, 返回它最后的输出。照 octo 的规矩:信号发整个进程组;只有 SIGKILL 才顺带 cancel ctx——SIGTERM 想给进程体面收尾的机会,cancel 会让 exec 抢跑一个自动 SIGKILL 把体面搅黄。
  2. detached 守护进程。第三种跑法:setsid 自立门户,stdout 重定向 到日志文件,不追踪、不收编、故意活过 harness 本体。想清楚它跟 interactive 的本质区别:interactive 是"会话的进程",detached 是 "机器的进程"。
  3. 同步超时自动转后台。把同步执行也改成隐藏的后台进程:超时不再 杀掉报错,而是"转正"成 async 任务继续跑,通知稍后送到。octo 还给 人留了 Ctrl+B 手动提前转正。
  4. 完成通知带同伴摘要。通知末尾附一句"还有 bg_2(npm test)在跑, 已 3 分钟"——模型不用一个专门的列表工具就能记住手上有几摊事。
  5. 把 30 秒 / 3 次调成可配置,然后故意调严(10 秒 / 1 次)跑一遍 实验四,观察模型被过早硬停之后的行为——防轮询的参数是宽容度和 止损速度的折中,调过头两边都疼。