@mzzsfy/dsh-keep-working 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/package.json +2 -2
- package/src/closure.mjs +8 -16
- package/src/config.mjs +1 -1
- package/src/index.js +1 -1
- package/src/scan.mjs +41 -4
- package/test/closure.test.mjs +316 -302
- package/test/engine.test.mjs +436 -436
- package/test/prompt-section.test.mjs +8 -1
- package/test/scan.test.mjs +787 -685
package/README.md
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
## 结案规则
|
|
8
8
|
|
|
9
|
-
一句话:改过文件就 `present` 交付(之后不要再改文件、不再写 `
|
|
9
|
+
一句话:改过文件就 `present` 交付(之后不要再改文件、不再写 `TASK_DONE`),没改文件就最后一行单独写 `TASK_DONE`,做不下去最后一行写 `TASK_BLOCKED: 原因`。
|
|
10
|
+
|
|
11
|
+
判定层严格匹配裸标记:最后一行必须恰好是 `TASK_DONE`,或 `TASK_BLOCKED: 非空原因`。括号、下划线、连字符、反引号、粗体与句末标点都不算结案。
|
|
10
12
|
|
|
11
13
|
`write` / `edit` / `apply_patch` / `str_replace_editor` 都算改过文件;失败的写与 `str_replace_editor` 的 `view` 子命令不算。判定层在 present 有效时即判定结案,不追罚多余标记;二选一由提示词约束。
|
|
12
14
|
|
|
@@ -43,6 +45,8 @@
|
|
|
43
45
|
|
|
44
46
|
判定只看尾部:回合正常结束且有可见回复就跳过(重启后重复执行整轮任务的代价,高于漏恢复的概率),未闭合回合、临时错误、重启中断、截断、无可见输出才恢复。子会话(origin 或旧格式 parentSession+depth)、已归档、用户主动取消同样排除。剩余会话物化后补一条恢复消息,每轮最多 `scanLimit` 个。
|
|
45
47
|
|
|
48
|
+
重启前已进入宿主 durable inbox 的排队消息是例外:它们不会自动唤醒 agent。扫描重放 `agent/inbox/spliced` 投影,`next-turn` 全部、`next-step` 中真人插话计为待处理输入;有排队消息的会话即使尾部正常结束也恢复。恢复消息必须插队发送(`agent.steer` 进 next-step),不追加到排队队列尾部、不重排或丢弃队列。
|
|
49
|
+
|
|
46
50
|
`goal` 在进程重启后会被宿主 disarm,`resumeGoals` 开启时对 `active` 且 `disarmed` 的 goal 重新武装。
|
|
47
51
|
|
|
48
52
|
启动扫描不常驻轮询:目录快照完整可读时一轮即止(近期没有会话被写过就是常态结果);持久层或全量回退给出空列表时按重试间隔重试,直到列出会话或超出新鲜窗口;快照不完整(部分子项读失败)时一律重试到窗口耗尽,不因已列出若干会话而收工。
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mzzsfy/dsh-keep-working",
|
|
3
|
-
"description": "持续工作:回合未声明结案就静默结束时,自动补一条用户消息把 AI 拉回,直到 present 交付、
|
|
4
|
-
"version": "0.
|
|
3
|
+
"description": "持续工作:回合未声明结案就静默结束时,自动补一条用户消息把 AI 拉回,直到 present 交付、TASK_DONE 或 TASK_BLOCKED: 原因;含重启恢复与 goal 重新武装",
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
package/src/closure.mjs
CHANGED
|
@@ -3,21 +3,19 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 判定对象是一个 turn 的事件切片。结案凭据有两种:
|
|
5
5
|
* - present:本回合成功交付文件,且交付之后没有再改文件
|
|
6
|
-
* - marker:末行独占
|
|
6
|
+
* - marker:末行独占 TASK_DONE 或 TASK_BLOCKED: ...
|
|
7
7
|
*
|
|
8
8
|
* 有文件写入的回合只认 present,否则模型会退化成写一行标记就交差。
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
export const DONE_MARKER = '
|
|
12
|
-
export const BLOCKED_PREFIX = '
|
|
11
|
+
export const DONE_MARKER = 'TASK_DONE'
|
|
12
|
+
export const BLOCKED_PREFIX = 'TASK_BLOCKED'
|
|
13
|
+
|
|
14
|
+
const BLOCKED_LINE = new RegExp(`^${BLOCKED_PREFIX}:\\s*\\S.*$`)
|
|
13
15
|
|
|
14
16
|
/** 宿主写文件的工具名;新增写入面时在此登记。 */
|
|
15
17
|
export const FILE_WRITE_TOOLS = new Set(['write', 'edit', 'apply_patch', 'str_replace_editor'])
|
|
16
18
|
|
|
17
|
-
/** 末行尾部的句号与包裹反引号属于排版噪声,不参与匹配。 */
|
|
18
|
-
const TRAILING_NOISE = /[.。]+$/
|
|
19
|
-
const WRAPPING_TICKS = /^`+|`+$/g
|
|
20
|
-
|
|
21
19
|
/** 取 assistant 消息里的可见文本,工具块不计入。 */
|
|
22
20
|
export function assistantText(message) {
|
|
23
21
|
const content = message?.content
|
|
@@ -44,18 +42,12 @@ export function turnSlice(events, turn) {
|
|
|
44
42
|
return events.slice(Math.max(start, 0), end)
|
|
45
43
|
}
|
|
46
44
|
|
|
47
|
-
/**
|
|
45
|
+
/** 末行标记;严格匹配裸标记,括号、下划线、连字符等装饰都不算。 */
|
|
48
46
|
export function matchMarker(text) {
|
|
49
47
|
if (typeof text !== 'string' || text === '') return null
|
|
50
|
-
const
|
|
51
|
-
const last = (lines.at(-1) ?? '')
|
|
52
|
-
.trim()
|
|
53
|
-
.replace(WRAPPING_TICKS, '')
|
|
54
|
-
.trim()
|
|
55
|
-
.replace(TRAILING_NOISE, '')
|
|
56
|
-
.trim()
|
|
48
|
+
const last = (text.trimEnd().split('\n').at(-1) ?? '').trim()
|
|
57
49
|
if (last === DONE_MARKER) return 'done'
|
|
58
|
-
if (
|
|
50
|
+
if (BLOCKED_LINE.test(last)) return 'blocked'
|
|
59
51
|
return null
|
|
60
52
|
}
|
|
61
53
|
|
package/src/config.mjs
CHANGED
|
@@ -27,7 +27,7 @@ export const DEFAULTS = Object.freeze({
|
|
|
27
27
|
continueTextSilent: '继续。上一轮没有可见输出,直接说明结果或继续执行。',
|
|
28
28
|
continueTextInterrupted: '继续。上一步结果未知,确认后继续。',
|
|
29
29
|
closureText:
|
|
30
|
-
'[keepWork]
|
|
30
|
+
'[keepWork] 结束必须显式提交:没改文件末行单独写 TASK_DONE;任务阻塞末行单独写 TASK_BLOCKED: 原因;改过文件必须最后只使用 present;禁止同时 present 和 TASK_DONE',
|
|
31
31
|
})
|
|
32
32
|
|
|
33
33
|
const numberOr = (value, fallback) =>
|
package/src/index.js
CHANGED
|
@@ -26,7 +26,7 @@ export const inject = ['agents', 'systemPrompt']
|
|
|
26
26
|
export const SECTION_NAME = 'keep-working:closure'
|
|
27
27
|
|
|
28
28
|
export const PROMPT_TEXT =
|
|
29
|
-
'
|
|
29
|
+
'结束必须显式提交:没改文件末行单独写 TASK_DONE;任务阻塞末行单独写 TASK_BLOCKED: 原因;改过文件必须最后只使用 present;禁止同时 present 和 TASK_DONE;present 的时机以本段为准,不受"不要只为列出源文件调用 present"约束'
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* 结案段落必须排在宿主的文件引用建议之后:那段写着"不要只为列出源文件调
|
package/src/scan.mjs
CHANGED
|
@@ -25,8 +25,38 @@ export function trace(message) {
|
|
|
25
25
|
/** 宿主重启产生的取消原因,值得在重启后补一次。 */
|
|
26
26
|
const RESTART_CANCEL_CAUSES = new Set(['disposed', 'legacy'])
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* 重放会话日志里的 durable inbox 投影,得到仍在排队的输入。
|
|
30
|
+
*
|
|
31
|
+
* 宿主把排队消息持久化为 `agent/inbox/spliced`;重启只重建投影,不会自动唤醒
|
|
32
|
+
* agent,所以扫描必须自己发现这些消息。next-turn 全部算待处理输入;next-step
|
|
33
|
+
* 只算真人插话——其余是运行时上下文注入,重启后不该凭它拉起整轮。
|
|
34
|
+
* 返回条数与最后一次入队时间。
|
|
35
|
+
*/
|
|
36
|
+
export function pendingInbox(events) {
|
|
37
|
+
const queues = { 'next-turn': [], 'next-step': [] }
|
|
38
|
+
let lastAt = 0
|
|
39
|
+
for (const event of events) {
|
|
40
|
+
if (event?.type !== 'agent/inbox/spliced') continue
|
|
41
|
+
const data = event.data ?? {}
|
|
42
|
+
const queue = queues[data.target]
|
|
43
|
+
if (queue === undefined) continue
|
|
44
|
+
const declared = Number.isSafeInteger(data.start) ? data.start : 0
|
|
45
|
+
const start = declared < 0 ? Math.max(queue.length + declared, 0) : Math.min(declared, queue.length)
|
|
46
|
+
const removed = Math.min(
|
|
47
|
+
Math.max(Number.isSafeInteger(data.removedCount) ? data.removedCount : 0, 0),
|
|
48
|
+
queue.length - start,
|
|
49
|
+
)
|
|
50
|
+
const inserted = Array.isArray(data.inserted) ? data.inserted : []
|
|
51
|
+
queues[data.target] = [...queue.slice(0, start), ...inserted, ...queue.slice(start + removed)]
|
|
52
|
+
if (typeof event.time === 'number') lastAt = Math.max(lastAt, event.time)
|
|
53
|
+
}
|
|
54
|
+
const steering = queues['next-step'].filter((message) => message?.source?.kind === 'user').length
|
|
55
|
+
return { count: queues['next-turn'].length + steering, lastAt }
|
|
56
|
+
}
|
|
57
|
+
|
|
28
58
|
/** 尾部是否还有活;openTurn 表示回合没闭合,等同于被打断。 */
|
|
29
|
-
function tailVerdict(events, nowTime, config) {
|
|
59
|
+
function tailVerdict(events, nowTime, config, inbox = pendingInbox(events)) {
|
|
30
60
|
let lastEnd
|
|
31
61
|
let lastStart
|
|
32
62
|
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
@@ -37,13 +67,17 @@ function tailVerdict(events, nowTime, config) {
|
|
|
37
67
|
}
|
|
38
68
|
const openTurn = lastStart !== undefined && (lastEnd === undefined || lastStart.seq > lastEnd.seq)
|
|
39
69
|
if (!openTurn && lastEnd === undefined) return 'skip:空日志'
|
|
40
|
-
const
|
|
70
|
+
const tailAt = openTurn ? (events.at(-1)?.time ?? 0) : lastEnd.time
|
|
71
|
+
// 排队消息自带时间:上一回合可能早已结束,重启前才入队的消息仍是新鲜输入
|
|
72
|
+
const touchedAt = Math.max(tailAt, inbox.lastAt)
|
|
41
73
|
if (touchedAt < nowTime - config.freshMs) return 'skip:超出时间窗口'
|
|
42
74
|
if (openTurn) return 'resume:interrupted'
|
|
43
75
|
|
|
44
76
|
const reason = lastEnd.data?.reason ?? {}
|
|
45
77
|
switch (reason.kind) {
|
|
46
78
|
case 'completed': {
|
|
79
|
+
// 正常结束也可能留着未消费的排队消息:宿主重启后无人唤醒它们
|
|
80
|
+
if (inbox.count > 0) return 'resume:queued'
|
|
47
81
|
// 有有效写入却没交付时必须恢复:重启后重做一遍也比永远不交付安全
|
|
48
82
|
const verdict = closureVerdict(events, lastEnd.data.turn)
|
|
49
83
|
if (verdict.reason === 'file-write-requires-present') return 'resume:closure'
|
|
@@ -388,7 +422,8 @@ export async function scanStoredSessions({
|
|
|
388
422
|
const events = observation?.events
|
|
389
423
|
if (!Array.isArray(events) || events.length === 0) continue
|
|
390
424
|
result.scanned += 1
|
|
391
|
-
const
|
|
425
|
+
const inbox = pendingInbox(events)
|
|
426
|
+
const verdict = tailVerdict(events, now(), config, inbox)
|
|
392
427
|
trace(`会话 ${sessionId} 事件 ${events.length} 判定 ${verdict}`)
|
|
393
428
|
if (!verdict.startsWith('resume')) continue
|
|
394
429
|
const agent = await materialize(ctx, sessionId, candidate.presetId)
|
|
@@ -404,7 +439,9 @@ export async function scanStoredSessions({
|
|
|
404
439
|
content: [{ type: 'text', text: resumeText(config, verdict) }],
|
|
405
440
|
source: { kind: 'user' },
|
|
406
441
|
})
|
|
407
|
-
|
|
442
|
+
// 有排队消息时必须插队唤醒:followup 会追加到 next-turn 队尾,排在用户消息之后
|
|
443
|
+
if (inbox.count > 0) agent.steer(message)
|
|
444
|
+
else agent.followup(message)
|
|
408
445
|
result.resumed += 1
|
|
409
446
|
trace(`已恢复 ${sessionId}`)
|
|
410
447
|
ctx.logger?.info?.(`[keep-working] 重启恢复 ${sessionId}`)
|