@wjj865-code/dsh-orchestrator 0.3.1 → 0.3.4

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/src/queue.js CHANGED
@@ -1,200 +1,200 @@
1
- // 派发队列:平台不排队(满了抛 ACTIVATION_LIMIT_REACHED),插件自己排。
2
- // 单锁串行:同一时刻只有一个 pump 在跑;spawn 成功 / 子会话结算 / 任务终态都会唤醒。
3
- import {
4
- briefText, clearQueued, countPending, getTask, listPendingQueue, listTasks, markQueued, transition,
5
- } from './ledger.js'
6
- import { taskLabel } from './task-label.js'
7
-
8
- export function createQueue({ db, dispatcher, config = {}, logger, notifier, retryDelaysMs = [2000, 5000, 10000, 30000] }) {
9
- const log = (event, fields) => {
10
- try { logger?.log?.(event, fields) } catch { /* 日志不能打断派发 */ }
11
- }
12
- const ids = []
13
- let pumpPromise = null
14
- let stopped = false
15
- let attempt = 0
16
- let timer = null
17
- // pump 是异步派发,不能复用 task_create 那次工具调用的 signal(回合结束会被 abort)。
18
- const abort = new AbortController()
19
-
20
- const position = id => {
21
- const index = ids.indexOf(id)
22
- return index === -1 ? 0 : index + 1
23
- }
24
- const enqueue = id => {
25
- if (!ids.includes(id)) ids.push(id)
26
- }
27
- const remove = id => {
28
- const index = ids.indexOf(id)
29
- if (index !== -1) ids.splice(index, 1)
30
- }
31
- const schedule = delay => {
32
- if (stopped) return
33
- clearTimeout(timer)
34
- timer = setTimeout(() => { timer = null; pump() }, delay)
35
- timer.unref?.()
36
- }
37
- // 容量满了 / 协调者还没加载:都算可重试,任务留在队列里,别标 failed。
38
- const retryable = error => error?.code === 'ACTIVATION_LIMIT_REACHED'
39
- || /未找到协调者会话|尚未就绪/.test(error?.message ?? '')
40
-
41
- async function pump() {
42
- if (stopped) return
43
- // 单锁:已有 pump 在跑时,新调用等同一个 promise,避免并发 create 各自提前返回导致漏派。
44
- if (pumpPromise !== null) return pumpPromise
45
- pumpPromise = runPump().finally(() => { pumpPromise = null })
46
- return pumpPromise
47
- }
48
-
49
- async function runPump() {
50
- let dispatched = 0
51
- try {
52
- while (ids.length > 0 && !stopped) {
53
- const id = ids[0]
54
- const task = getTask(db, id)
55
- if (task === undefined || task.status !== 'pending' || task.session_id !== null) {
56
- ids.shift()
57
- continue
58
- }
59
- const rendered = briefText(db, id) ?? `【任务】${task.title}\n目标:${task.goal}`
60
- let member
61
- try {
62
- member = await dispatcher.spawn({
63
- name: dispatcher.slug?.(task.title) ?? task.title,
64
- label: task.title,
65
- prompt: rendered,
66
- signal: abort.signal,
67
- taskId: id,
68
- cwd: task.target_dir ?? undefined,
69
- readOnly: task.read_only === true,
70
- })
71
- } catch (error) {
72
- if (retryable(error)) {
73
- // 容量满:整条队列都算「排队中」,不能只标队首,否则重启时后面的任务会被当派发中断误杀。
74
- for (const queuedId of [...ids]) {
75
- const queuedTask = getTask(db, queuedId)
76
- if (queuedTask !== undefined && queuedTask.status === 'pending') markQueued(db, queuedId)
77
- }
78
- log('task.spawn.blocked', {
79
- taskId: id,
80
- detail: { reason: error?.message ?? String(error), queued: ids.length },
81
- })
82
- const delay = retryDelaysMs[Math.min(attempt, retryDelaysMs.length - 1)]
83
- attempt += 1
84
- schedule(delay)
85
- return
86
- }
87
- remove(id)
88
- const reason = error?.message ?? String(error)
89
- const message = /^未找到协调者会话|^协调者会话尚未就绪/.test(reason) ? reason : `子会话创建失败:${reason}`
90
- const stack = typeof error?.stack === 'string' ? error.stack.split('\n').slice(0, 3).join(' | ') : null
91
- log('task.spawn.failed', { taskId: id, detail: { error: reason, ...(stack === null ? {} : { stack }) } })
92
- try {
93
- transition(db, id, 'failed', { error: message })
94
- } catch (failure) {
95
- log('task.spawn.transition-failed', {
96
- taskId: id,
97
- detail: { error: failure?.message ?? String(failure) },
98
- })
99
- }
100
- // 终态释放 target 可写根(异常路径也不能留授权)。
101
- try {
102
- dispatcher.releaseWritableRoots?.(task.session_id ?? task.target)
103
- } catch { /* 释放失败不影响失败通知 */ }
104
- // 失败必须让主会话看见(不能静默 failed);sender 缺失时由 index 用主会话自身投递。
105
- notifier?.push({
106
- taskId: id,
107
- title: task.title,
108
- displayName: taskLabel(task, { all: listTasks(db) }),
109
- readOnly: task.read_only === true,
110
- status: 'failed',
111
- summary: message,
112
- system: true,
113
- })
114
- continue
115
- }
116
-
117
- // spawn 在途时任务可能已被 task_stop/取消:把刚起的子会话收掉,别让它白占一个并发位,
118
- // 也不能把队列里后面的任务一起 shift 掉(审查 probe-queue-race)。
119
- const sessionId = member.sessionId ?? member.id
120
- const target = member.target ?? member.name
121
- const current = getTask(db, id)
122
- if (current === undefined || current.status !== 'pending') {
123
- remove(id)
124
- try {
125
- await dispatcher.stop(target)
126
- } catch (error) {
127
- log('task.spawn.discarded', { taskId: id, detail: { target, disposeError: error?.message ?? String(error) } })
128
- continue
129
- }
130
- log('task.spawn.discarded', {
131
- taskId: id,
132
- sessionId,
133
- detail: { target, status: current?.status ?? 'missing' },
134
- })
135
- continue
136
- }
137
- try {
138
- clearQueued(db, id)
139
- transition(db, id, 'running', { sessionId, target })
140
- remove(id)
141
- log('task.spawn.ok', { taskId: id, sessionId, detail: { target } })
142
- dispatched += 1
143
- attempt = 0
144
- } catch (error) {
145
- // 状态迁移失败:子会话已经起出来了,best-effort 收掉,别白占并发位。
146
- // 这时任务 session_id 还是空的,孤儿对账筛不到它,所以这里必须自己收。
147
- remove(id)
148
- try {
149
- await dispatcher.stop(target)
150
- } catch (stopError) {
151
- log('task.spawn.stop-failed', {
152
- taskId: id,
153
- sessionId,
154
- detail: { target, error: stopError?.message ?? String(stopError) },
155
- })
156
- }
157
- log('task.spawn.transition-failed', {
158
- taskId: id,
159
- sessionId,
160
- detail: { target, error: error?.message ?? String(error) },
161
- })
162
- }
163
- }
164
- if (dispatched > 0) log('task.queue.drained', { detail: { dispatched } })
165
- } catch (error) {
166
- // runPump 自身异常(台账写失败等)不能把插件带崩,也不能卡住队列。
167
- log('task.queue.error', { detail: { error: error?.message ?? String(error) } })
168
- }
169
- }
170
-
171
- async function enqueueAndPump(id) {
172
- enqueue(id)
173
- await pump()
174
- return { task: getTask(db, id), position: position(id) }
175
- }
176
-
177
- /** 启动恢复:把 pending 且没有子会话的任务放进内存队列(是否立刻 pump 由调用方决定)。 */
178
- function load() {
179
- for (const task of listPendingQueue(db)) enqueue(task.id)
180
- }
181
-
182
- function stop() {
183
- stopped = true
184
- clearTimeout(timer)
185
- ids.length = 0
186
- try { abort.abort() } catch { /* 卸载期 abort 失败不阻断 */ }
187
- }
188
-
189
- return {
190
- enqueue,
191
- enqueueAndPump,
192
- remove,
193
- position,
194
- pump,
195
- load,
196
- stop,
197
- size: () => ids.length,
198
- pendingCount: () => countPending(db),
199
- }
200
- }
1
+ // 派发队列:平台不排队(满了抛 ACTIVATION_LIMIT_REACHED),插件自己排。
2
+ // 单锁串行:同一时刻只有一个 pump 在跑;spawn 成功 / 子会话结算 / 任务终态都会唤醒。
3
+ import {
4
+ briefText, clearQueued, countPending, getTask, listPendingQueue, listTasks, markQueued, transition,
5
+ } from './ledger.js'
6
+ import { taskLabel } from './task-label.js'
7
+
8
+ export function createQueue({ db, dispatcher, config = {}, logger, notifier, retryDelaysMs = [2000, 5000, 10000, 30000] }) {
9
+ const log = (event, fields) => {
10
+ try { logger?.log?.(event, fields) } catch { /* 日志不能打断派发 */ }
11
+ }
12
+ const ids = []
13
+ let pumpPromise = null
14
+ let stopped = false
15
+ let attempt = 0
16
+ let timer = null
17
+ // pump 是异步派发,不能复用 task_create 那次工具调用的 signal(回合结束会被 abort)。
18
+ const abort = new AbortController()
19
+
20
+ const position = id => {
21
+ const index = ids.indexOf(id)
22
+ return index === -1 ? 0 : index + 1
23
+ }
24
+ const enqueue = id => {
25
+ if (!ids.includes(id)) ids.push(id)
26
+ }
27
+ const remove = id => {
28
+ const index = ids.indexOf(id)
29
+ if (index !== -1) ids.splice(index, 1)
30
+ }
31
+ const schedule = delay => {
32
+ if (stopped) return
33
+ clearTimeout(timer)
34
+ timer = setTimeout(() => { timer = null; pump() }, delay)
35
+ timer.unref?.()
36
+ }
37
+ // 容量满了 / 协调者还没加载:都算可重试,任务留在队列里,别标 failed。
38
+ const retryable = error => error?.code === 'ACTIVATION_LIMIT_REACHED'
39
+ || /未找到协调者会话|尚未就绪/.test(error?.message ?? '')
40
+
41
+ async function pump() {
42
+ if (stopped) return
43
+ // 单锁:已有 pump 在跑时,新调用等同一个 promise,避免并发 create 各自提前返回导致漏派。
44
+ if (pumpPromise !== null) return pumpPromise
45
+ pumpPromise = runPump().finally(() => { pumpPromise = null })
46
+ return pumpPromise
47
+ }
48
+
49
+ async function runPump() {
50
+ let dispatched = 0
51
+ try {
52
+ while (ids.length > 0 && !stopped) {
53
+ const id = ids[0]
54
+ const task = getTask(db, id)
55
+ if (task === undefined || task.status !== 'pending' || task.session_id !== null) {
56
+ ids.shift()
57
+ continue
58
+ }
59
+ const rendered = briefText(db, id) ?? `【任务】${task.title}\n目标:${task.goal}`
60
+ let member
61
+ try {
62
+ member = await dispatcher.spawn({
63
+ name: dispatcher.slug?.(task.title) ?? task.title,
64
+ label: task.title,
65
+ prompt: rendered,
66
+ signal: abort.signal,
67
+ taskId: id,
68
+ cwd: task.target_dir ?? undefined,
69
+ readOnly: task.read_only === true,
70
+ })
71
+ } catch (error) {
72
+ if (retryable(error)) {
73
+ // 容量满:整条队列都算「排队中」,不能只标队首,否则重启时后面的任务会被当派发中断误杀。
74
+ for (const queuedId of [...ids]) {
75
+ const queuedTask = getTask(db, queuedId)
76
+ if (queuedTask !== undefined && queuedTask.status === 'pending') markQueued(db, queuedId)
77
+ }
78
+ log('task.spawn.blocked', {
79
+ taskId: id,
80
+ detail: { reason: error?.message ?? String(error), queued: ids.length },
81
+ })
82
+ const delay = retryDelaysMs[Math.min(attempt, retryDelaysMs.length - 1)]
83
+ attempt += 1
84
+ schedule(delay)
85
+ return
86
+ }
87
+ remove(id)
88
+ const reason = error?.message ?? String(error)
89
+ const message = /^未找到协调者会话|^协调者会话尚未就绪/.test(reason) ? reason : `子会话创建失败:${reason}`
90
+ const stack = typeof error?.stack === 'string' ? error.stack.split('\n').slice(0, 3).join(' | ') : null
91
+ log('task.spawn.failed', { taskId: id, detail: { error: reason, ...(stack === null ? {} : { stack }) } })
92
+ try {
93
+ transition(db, id, 'failed', { error: message })
94
+ } catch (failure) {
95
+ log('task.spawn.transition-failed', {
96
+ taskId: id,
97
+ detail: { error: failure?.message ?? String(failure) },
98
+ })
99
+ }
100
+ // 终态释放 target 可写根(异常路径也不能留授权)。
101
+ try {
102
+ dispatcher.releaseWritableRoots?.(task.session_id ?? task.target)
103
+ } catch { /* 释放失败不影响失败通知 */ }
104
+ // 失败必须让主会话看见(不能静默 failed);sender 缺失时由 index 用主会话自身投递。
105
+ notifier?.push({
106
+ taskId: id,
107
+ title: task.title,
108
+ displayName: taskLabel(task, { all: listTasks(db) }),
109
+ readOnly: task.read_only === true,
110
+ status: 'failed',
111
+ summary: message,
112
+ system: true,
113
+ })
114
+ continue
115
+ }
116
+
117
+ // spawn 在途时任务可能已被 task_stop/取消:把刚起的子会话收掉,别让它白占一个并发位,
118
+ // 也不能把队列里后面的任务一起 shift 掉(审查 probe-queue-race)。
119
+ const sessionId = member.sessionId ?? member.id
120
+ const target = member.target ?? member.name
121
+ const current = getTask(db, id)
122
+ if (current === undefined || current.status !== 'pending') {
123
+ remove(id)
124
+ try {
125
+ await dispatcher.stop(target)
126
+ } catch (error) {
127
+ log('task.spawn.discarded', { taskId: id, detail: { target, disposeError: error?.message ?? String(error) } })
128
+ continue
129
+ }
130
+ log('task.spawn.discarded', {
131
+ taskId: id,
132
+ sessionId,
133
+ detail: { target, status: current?.status ?? 'missing' },
134
+ })
135
+ continue
136
+ }
137
+ try {
138
+ clearQueued(db, id)
139
+ transition(db, id, 'running', { sessionId, target })
140
+ remove(id)
141
+ log('task.spawn.ok', { taskId: id, sessionId, detail: { target } })
142
+ dispatched += 1
143
+ attempt = 0
144
+ } catch (error) {
145
+ // 状态迁移失败:子会话已经起出来了,best-effort 收掉,别白占并发位。
146
+ // 这时任务 session_id 还是空的,孤儿对账筛不到它,所以这里必须自己收。
147
+ remove(id)
148
+ try {
149
+ await dispatcher.stop(target)
150
+ } catch (stopError) {
151
+ log('task.spawn.stop-failed', {
152
+ taskId: id,
153
+ sessionId,
154
+ detail: { target, error: stopError?.message ?? String(stopError) },
155
+ })
156
+ }
157
+ log('task.spawn.transition-failed', {
158
+ taskId: id,
159
+ sessionId,
160
+ detail: { target, error: error?.message ?? String(error) },
161
+ })
162
+ }
163
+ }
164
+ if (dispatched > 0) log('task.queue.drained', { detail: { dispatched } })
165
+ } catch (error) {
166
+ // runPump 自身异常(台账写失败等)不能把插件带崩,也不能卡住队列。
167
+ log('task.queue.error', { detail: { error: error?.message ?? String(error) } })
168
+ }
169
+ }
170
+
171
+ async function enqueueAndPump(id) {
172
+ enqueue(id)
173
+ await pump()
174
+ return { task: getTask(db, id), position: position(id) }
175
+ }
176
+
177
+ /** 启动恢复:把 pending 且没有子会话的任务放进内存队列(是否立刻 pump 由调用方决定)。 */
178
+ function load() {
179
+ for (const task of listPendingQueue(db)) enqueue(task.id)
180
+ }
181
+
182
+ function stop() {
183
+ stopped = true
184
+ clearTimeout(timer)
185
+ ids.length = 0
186
+ try { abort.abort() } catch { /* 卸载期 abort 失败不阻断 */ }
187
+ }
188
+
189
+ return {
190
+ enqueue,
191
+ enqueueAndPump,
192
+ remove,
193
+ position,
194
+ pump,
195
+ load,
196
+ stop,
197
+ size: () => ids.length,
198
+ pendingCount: () => countPending(db),
199
+ }
200
+ }
package/src/reconcile.js CHANGED
@@ -1,60 +1,60 @@
1
- // 启动/协调者加载对账。
2
- // - running 且子代理不 live(进程重启后本来就没有在跑的回合)→ orphaned,别留「台账在跑、实际没人在跑」的幽灵;
3
- // - waiting_user/waiting_auth 即使子级不 live 也保持(它们是故意停下的,靠 task_message 冷恢复);
4
- // - catalog 里查不到 session_id → 同样 orphaned(已丢失,可重建);
5
- // - catalog 读不到只 warn:running 的 liveness 判定照做,其余保留现状。
6
- import { listTasks, transition } from './ledger.js'
7
-
8
- const NEEDS_CHECK = new Set(['running', 'waiting_user', 'waiting_auth'])
9
-
10
- export async function reconcileOrphans({ db, dispatcher, coordinatorSessionId, isLive, logger, onWarn = () => {} }) {
11
- const active = listTasks(db).filter(task =>
12
- task.session_id !== null && task.session_id !== undefined && NEEDS_CHECK.has(task.status))
13
- if (active.length === 0) return { count: 0, ids: [], skipped: 'nothing-active' }
14
- if (coordinatorSessionId === undefined) {
15
- onWarn('dsh-orchestrator: 未绑定协调者会话,跳过子会话可达性对账')
16
- return { count: 0, ids: [], skipped: 'no-coordinator' }
17
- }
18
-
19
- let known
20
- try {
21
- known = new Set((await dispatcher.catalog() ?? []).map(entry => String(entry.id)))
22
- } catch (error) {
23
- known = undefined
24
- onWarn(`dsh-orchestrator: 子会话目录读取失败,跳过 catalog 对账:${error?.message ?? error}`)
25
- }
26
-
27
- const orphaned = []
28
- for (const task of active) {
29
- const sessionId = String(task.session_id)
30
- if (task.status === 'running' && typeof isLive === 'function' && !isLive(sessionId)) {
31
- orphaned.push({ task, reason: '进程重启后子会话没有在运行的回合(可重建)' })
32
- continue
33
- }
34
- if (known !== undefined && !known.has(sessionId)) {
35
- orphaned.push({ task, reason: '子会话在目录里查不到(可能已被删除)' })
36
- }
37
- }
38
-
39
- for (const { task, reason } of orphaned) {
40
- try {
41
- transition(db, task.id, 'orphaned', { error: reason })
42
- // orphaned 是终态:释放该会话的 target 可写根。
43
- try {
44
- dispatcher.releaseWritableRoots?.(task.session_id ?? task.target)
45
- } catch { /* 释放失败不影响对账 */ }
46
- } catch (error) {
47
- onWarn(`dsh-orchestrator: 任务 ${task.id} 标 orphaned 失败:${error?.message ?? error}`)
48
- }
49
- }
50
- try {
51
- logger?.log?.('task.reconciled', {
52
- detail: {
53
- checked: active.length,
54
- orphaned: orphaned.map(({ task }) => task.id),
55
- catalog: known === undefined ? 'unavailable' : 'ok',
56
- },
57
- })
58
- } catch { /* 日志失败不影响对账 */ }
59
- return { count: orphaned.length, ids: orphaned.map(({ task }) => task.id) }
60
- }
1
+ // 启动/协调者加载对账。
2
+ // - running 且子代理不 live(进程重启后本来就没有在跑的回合)→ orphaned,别留「台账在跑、实际没人在跑」的幽灵;
3
+ // - waiting_user/waiting_auth 即使子级不 live 也保持(它们是故意停下的,靠 task_message 冷恢复);
4
+ // - catalog 里查不到 session_id → 同样 orphaned(已丢失,可重建);
5
+ // - catalog 读不到只 warn:running 的 liveness 判定照做,其余保留现状。
6
+ import { listTasks, transition } from './ledger.js'
7
+
8
+ const NEEDS_CHECK = new Set(['running', 'waiting_user', 'waiting_auth'])
9
+
10
+ export async function reconcileOrphans({ db, dispatcher, coordinatorSessionId, isLive, logger, onWarn = () => {} }) {
11
+ const active = listTasks(db).filter(task =>
12
+ task.session_id !== null && task.session_id !== undefined && NEEDS_CHECK.has(task.status))
13
+ if (active.length === 0) return { count: 0, ids: [], skipped: 'nothing-active' }
14
+ if (coordinatorSessionId === undefined) {
15
+ onWarn('dsh-orchestrator: 未绑定协调者会话,跳过子会话可达性对账')
16
+ return { count: 0, ids: [], skipped: 'no-coordinator' }
17
+ }
18
+
19
+ let known
20
+ try {
21
+ known = new Set((await dispatcher.catalog() ?? []).map(entry => String(entry.id)))
22
+ } catch (error) {
23
+ known = undefined
24
+ onWarn(`dsh-orchestrator: 子会话目录读取失败,跳过 catalog 对账:${error?.message ?? error}`)
25
+ }
26
+
27
+ const orphaned = []
28
+ for (const task of active) {
29
+ const sessionId = String(task.session_id)
30
+ if (task.status === 'running' && typeof isLive === 'function' && !isLive(sessionId)) {
31
+ orphaned.push({ task, reason: '进程重启后子会话没有在运行的回合(可重建)' })
32
+ continue
33
+ }
34
+ if (known !== undefined && !known.has(sessionId)) {
35
+ orphaned.push({ task, reason: '子会话在目录里查不到(可能已被删除)' })
36
+ }
37
+ }
38
+
39
+ for (const { task, reason } of orphaned) {
40
+ try {
41
+ transition(db, task.id, 'orphaned', { error: reason })
42
+ // orphaned 是终态:释放该会话的 target 可写根。
43
+ try {
44
+ dispatcher.releaseWritableRoots?.(task.session_id ?? task.target)
45
+ } catch { /* 释放失败不影响对账 */ }
46
+ } catch (error) {
47
+ onWarn(`dsh-orchestrator: 任务 ${task.id} 标 orphaned 失败:${error?.message ?? error}`)
48
+ }
49
+ }
50
+ try {
51
+ logger?.log?.('task.reconciled', {
52
+ detail: {
53
+ checked: active.length,
54
+ orphaned: orphaned.map(({ task }) => task.id),
55
+ catalog: known === undefined ? 'unavailable' : 'ok',
56
+ },
57
+ })
58
+ } catch { /* 日志失败不影响对账 */ }
59
+ return { count: orphaned.length, ids: orphaned.map(({ task }) => task.id) }
60
+ }
@@ -2,6 +2,8 @@
2
2
  // 规则(增量计划):精确名优先;只在最高命中层里取结果;唯一命中才用,多命中列候选,绝不猜。
3
3
  const normalize = value => typeof value === 'string' ? value.trim().toLowerCase() : ''
4
4
  const compact = value => value.replace(/[^a-z0-9]+/g, '')
5
+ // 显式用中文排序,避免 Linux runner 的默认 locale(en-US)把「项目甲/项目乙」排反。
6
+ const titleCollator = new Intl.Collator('zh-CN', { sensitivity: 'base' })
5
7
 
6
8
  /** 纯 ASCII 键要求至少 3 个字母数字,CJK 等键至少 2 个字,避免 `ai`/`main` 这类短词误命中。 */
7
9
  const isMatchable = key => {
@@ -75,6 +77,6 @@ export function inferWorkspaceTarget(brief = {}, workspaces = []) {
75
77
  title: typeof workspace.title === 'string' ? workspace.title : pathTail(workspace.path),
76
78
  path: workspace.path,
77
79
  }))
78
- .sort((a, b) => String(a.title).localeCompare(String(b.title)))
80
+ .sort((a, b) => titleCollator.compare(String(a.title), String(b.title)))
79
81
  return { ok: false, candidates }
80
82
  }