@goodandready/dsh-kanban 0.2.19 → 0.2.21
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/lib/board-tool.js +18 -48
- package/lib/client.js +85 -176
- package/lib/commands.js +21 -38
- package/lib/config.js +30 -55
- package/lib/cron.js +13 -13
- package/lib/events.js +17 -5
- package/lib/filters.js +58 -82
- package/lib/git-provider.js +23 -23
- package/lib/gitea.js +59 -110
- package/lib/github.js +39 -37
- package/lib/import.js +46 -67
- package/lib/index.js +131 -140
- package/lib/intake.js +18 -26
- package/lib/launcher.js +107 -170
- package/lib/lifecycle.js +11 -17
- package/lib/markdown.js +16 -23
- package/lib/metrics.js +23 -25
- package/lib/order.js +25 -28
- package/lib/outbound.js +28 -28
- package/lib/plan.js +23 -45
- package/lib/power.js +2 -2
- package/lib/progress-dump.js +17 -17
- package/lib/routes.js +92 -145
- package/lib/store.js +68 -77
- package/lib/sync.js +56 -98
- package/lib/time.js +8 -17
- package/lib/transitions.js +44 -61
- package/lib/webhook.js +16 -23
- package/lib/workflow-bridge.js +72 -68
- package/lib/worktree.js +103 -60
- package/package.json +1 -1
package/lib/launcher.js
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Task execution launcher: dedicated agent session with chosen model.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// origin/main. Плагин, создающий ветку заранее, ломает preflight: ветка
|
|
7
|
-
// появится до того, как выяснится, что задачу вообще нельзя начинать.
|
|
3
|
+
// The plugin does not prepare branches or worktrees in advance: the agent conducts
|
|
4
|
+
// preflight first to check existing worktrees and branch status before branching from origin/main.
|
|
5
|
+
// Pre-creating branches would interfere with preflight validation.
|
|
8
6
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
7
|
+
// The agent writes the start comment on the issue after preflight, as branch
|
|
8
|
+
// and plan are determined during preflight.
|
|
11
9
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
10
|
+
// Core packages are not imported directly; session ID and message factory
|
|
11
|
+
// are passed as parameters to support unit testing without harness runtime.
|
|
14
12
|
|
|
15
13
|
import { isAbsolute, join, resolve, sep } from 'node:path'
|
|
16
14
|
import { randomUUID } from 'node:crypto'
|
|
@@ -20,34 +18,30 @@ import { formatHandoverPreamble, parseProgressDump } from './progress-dump.js'
|
|
|
20
18
|
export const PERMISSION_RANKS = { 'read-only': 0, 'workspace-write': 1, 'danger-full-access': 2 }
|
|
21
19
|
|
|
22
20
|
/**
|
|
23
|
-
*
|
|
21
|
+
* Tail instructions for local board tasks.
|
|
24
22
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* такой задачи бессмысленно — агент честно уходил искать репозитории и
|
|
28
|
-
* перечислять worktree вместо того, чтобы заняться написанным.
|
|
29
|
-
*
|
|
30
|
-
* Поэтому решает агент по содержанию задачи, а не доска заранее.
|
|
23
|
+
* Local tasks cover ad-hoc activities where repo preflight and branch creation
|
|
24
|
+
* may not apply. The agent evaluates task context rather than forcing repo workflows.
|
|
31
25
|
*/
|
|
32
|
-
const OWN_TAIL = '
|
|
33
|
-
+ '
|
|
26
|
+
const OWN_TAIL = 'If the task is about project code, follow its workflow; '
|
|
27
|
+
+ 'if not, just perform what is written above.'
|
|
34
28
|
|
|
35
|
-
/**
|
|
29
|
+
/** Tail instructions for workflow tasks backed by a Gitea issue. */
|
|
36
30
|
const WORKFLOW_TAIL = [
|
|
37
|
-
'
|
|
38
|
-
'
|
|
39
|
-
'
|
|
31
|
+
'Follow active project workflow: first preflight open branches and',
|
|
32
|
+
'worktrees, then worktree from fresh origin/main, initial issue comment',
|
|
33
|
+
'with plan and checks, then implementation.',
|
|
40
34
|
].join('\n')
|
|
41
35
|
|
|
42
36
|
/**
|
|
43
|
-
*
|
|
37
|
+
* Initial message prompt for a task.
|
|
44
38
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
39
|
+
* Does not instruct the agent to use a specific branch name; the agent selects
|
|
40
|
+
* branch naming following project workflow after preflight.
|
|
47
41
|
*
|
|
48
|
-
* @param {object} task
|
|
49
|
-
* @param {object} config
|
|
50
|
-
* @returns {string}
|
|
42
|
+
* @param {object} task board task
|
|
43
|
+
* @param {object} config plugin settings
|
|
44
|
+
* @returns {string} prompt message body
|
|
51
45
|
*/
|
|
52
46
|
export function buildStartMessage(task, config) {
|
|
53
47
|
const vars = {
|
|
@@ -63,22 +57,22 @@ export function buildStartMessage(task, config) {
|
|
|
63
57
|
const custom = typeof config?.startPrompt === 'string' ? config.startPrompt.trim() : ''
|
|
64
58
|
if (custom !== '') return joinBlocks([substitute(custom, vars), note])
|
|
65
59
|
|
|
66
|
-
//
|
|
60
|
+
// Issue tasks and local board tasks use distinct prompt layouts.
|
|
67
61
|
const fromIssue = vars.repo !== '' && vars.issueNumber !== ''
|
|
68
62
|
const head = fromIssue
|
|
69
|
-
?
|
|
70
|
-
:
|
|
63
|
+
? `Task ${vars.repo}#${vars.issueNumber}: ${vars.title}`
|
|
64
|
+
: `Task: ${vars.title}`
|
|
71
65
|
|
|
72
66
|
return joinBlocks([
|
|
73
67
|
head,
|
|
74
68
|
vars.body,
|
|
75
|
-
fromIssue && vars.issueUrl !== '' ?
|
|
69
|
+
fromIssue && vars.issueUrl !== '' ? `Link: ${vars.issueUrl}` : '',
|
|
76
70
|
fromIssue ? WORKFLOW_TAIL : OWN_TAIL,
|
|
77
71
|
note,
|
|
78
72
|
])
|
|
79
73
|
}
|
|
80
74
|
|
|
81
|
-
/**
|
|
75
|
+
/** Join non-empty blocks with double newlines. */
|
|
82
76
|
function joinBlocks(parts) {
|
|
83
77
|
return parts.map((p) => String(p ?? '').trim()).filter(Boolean).join('\n\n')
|
|
84
78
|
}
|
|
@@ -92,23 +86,18 @@ function substitute(template, vars) {
|
|
|
92
86
|
}
|
|
93
87
|
|
|
94
88
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* Корневой checkout по воркфлоу только для чтения, и это ровно то место, где
|
|
98
|
-
* положено начинать: preflight читает состояние репозитория. Worktree заведёт
|
|
99
|
-
* агент.
|
|
89
|
+
* Working directory for session — root checkout of the project, not a worktree.
|
|
100
90
|
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* в произвольный каталог.
|
|
91
|
+
* Root checkout is read-only per workflow guidelines; preflight checks run here.
|
|
92
|
+
* Path traversal outside project root is rejected.
|
|
104
93
|
*
|
|
105
|
-
* @throws
|
|
94
|
+
* @throws if root is not absolute or repository path escapes project root fence
|
|
106
95
|
*/
|
|
107
96
|
export function resolveCwd(task, config, cwdOf = () => process.cwd()) {
|
|
108
97
|
const root = typeof config?.defaultProjectRoot === 'string' && config.defaultProjectRoot.trim() !== ''
|
|
109
98
|
? config.defaultProjectRoot.trim()
|
|
110
99
|
: cwdOf()
|
|
111
|
-
if (!isAbsolute(root)) throw new Error('
|
|
100
|
+
if (!isAbsolute(root)) throw new Error('project root must be an absolute path')
|
|
112
101
|
|
|
113
102
|
const repo = typeof task?.repo === 'string' ? task.repo.trim() : ''
|
|
114
103
|
if (repo === '') return root
|
|
@@ -117,27 +106,20 @@ export function resolveCwd(task, config, cwdOf = () => process.cwd()) {
|
|
|
117
106
|
const fence = resolve(root)
|
|
118
107
|
const fenceSep = fence.endsWith(sep) || fence.endsWith('/') ? fence : fence + sep
|
|
119
108
|
if (target !== fence && !target.startsWith(fenceSep) && !target.startsWith(fence.endsWith('/') ? fence : fence + '/')) {
|
|
120
|
-
throw new Error('
|
|
109
|
+
throw new Error('repository name escapes project root')
|
|
121
110
|
}
|
|
122
111
|
return target
|
|
123
112
|
}
|
|
124
113
|
|
|
125
114
|
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* Правило владельца — **одна задача, один чат**, сколько бы этапов в ней ни
|
|
129
|
-
* было. Поэтому новую сессию поднимаем только когда прежней взять неоткуда.
|
|
115
|
+
* Retrieve agent for task: active, resumed, or newly spawned.
|
|
130
116
|
*
|
|
131
|
-
*
|
|
132
|
-
* такого вопроса у ядра нет, а `resume` сам скажет отказом. Пробуем и ловим.
|
|
133
|
-
*
|
|
134
|
-
* @returns {{agent: object, sessionId: string, mode: 'opened'|'resumed'|'created'}}
|
|
117
|
+
* Enforces one chat session per task. Reuses existing session when possible.
|
|
135
118
|
*/
|
|
136
119
|
export function applyPermission({ permissions, agent, name }) {
|
|
137
120
|
if (typeof name !== 'string' || name === '') return { skipped: 'not-picked' }
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
// пошла бы с правами по умолчанию, то есть, возможно, БОЛЬШИМИ, чем просили.
|
|
121
|
+
// Permission service is optional. If permission level is selected but service
|
|
122
|
+
// is unavailable, execution fails rather than running with broader default permissions.
|
|
141
123
|
if (permissions === undefined || typeof permissions.set !== 'function') {
|
|
142
124
|
return { error: 'permission-unavailable' }
|
|
143
125
|
}
|
|
@@ -156,17 +138,17 @@ export async function obtainAgent({
|
|
|
156
138
|
const known = typeof task?.sessionId === 'string' && task.sessionId !== '' ? task.sessionId : undefined
|
|
157
139
|
|
|
158
140
|
if (known !== undefined) {
|
|
159
|
-
//
|
|
141
|
+
// Live agent — reuse active session.
|
|
160
142
|
let live
|
|
161
143
|
try { live = agents.get(known) } catch { live = undefined }
|
|
162
144
|
if (live !== undefined) return { agent: live, sessionId: known, mode: 'opened' }
|
|
163
145
|
|
|
164
|
-
//
|
|
146
|
+
// Session suspended; attempt resume.
|
|
165
147
|
try {
|
|
166
148
|
const handle = await agents.resume({ resumeSessionId: known, agentOptions: { provider, model } })
|
|
167
149
|
return { agent: handle.agent, sessionId: handle.agent.session.id, mode: 'resumed' }
|
|
168
150
|
} catch (error) {
|
|
169
|
-
logger?.warn?.(`dsh-kanban:
|
|
151
|
+
logger?.warn?.(`dsh-kanban: session ${known} resume failed: ${error?.message}`)
|
|
170
152
|
}
|
|
171
153
|
}
|
|
172
154
|
|
|
@@ -200,11 +182,11 @@ export async function obtainAgent({
|
|
|
200
182
|
}
|
|
201
183
|
}
|
|
202
184
|
} catch (err) {
|
|
203
|
-
logger?.warn?.(`dsh-kanban:
|
|
185
|
+
logger?.warn?.(`dsh-kanban: failed to create worktree for ${task.id}: ${err.message}`)
|
|
204
186
|
}
|
|
205
187
|
}
|
|
206
188
|
|
|
207
|
-
// meta.cwd
|
|
189
|
+
// meta.cwd preserves projectRoot so DSH groups session under the project (#227)
|
|
208
190
|
const meta = { cwd: projectRoot, projectRoot }
|
|
209
191
|
if (worktreePath) meta.worktreePath = worktreePath
|
|
210
192
|
if (branchName) meta.branch = branchName
|
|
@@ -213,17 +195,16 @@ export async function obtainAgent({
|
|
|
213
195
|
|
|
214
196
|
const handle = await agents.create({
|
|
215
197
|
sessionId: mintSessionId(),
|
|
216
|
-
//
|
|
217
|
-
// едет здесь, а не отдельным вызовом после.
|
|
198
|
+
// Profile is set during session creation.
|
|
218
199
|
meta,
|
|
219
200
|
agentOptions: { provider, model },
|
|
220
201
|
})
|
|
221
202
|
const access = applyPermission({ permissions, agent: handle.agent, name: permission })
|
|
222
203
|
if (access.error !== undefined) {
|
|
223
|
-
//
|
|
224
|
-
try { handle.dispose?.() } catch { /*
|
|
225
|
-
logger?.warn?.(`dsh-kanban:
|
|
226
|
-
const error = new Error('
|
|
204
|
+
// Session already initialized; preserve configured permissions.
|
|
205
|
+
try { handle.dispose?.() } catch { /* cleanup is secondary to rejection */ }
|
|
206
|
+
logger?.warn?.(`dsh-kanban: access tier not set: ${access.reason ?? access.error}`)
|
|
207
|
+
const error = new Error('access tier not set')
|
|
227
208
|
error.key = access.error
|
|
228
209
|
error.status = 409
|
|
229
210
|
throw error
|
|
@@ -240,40 +221,30 @@ export async function obtainAgent({
|
|
|
240
221
|
}
|
|
241
222
|
|
|
242
223
|
/**
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
* Порядок важен: сначала берём агента, дожидаемся простоя, отправляем
|
|
246
|
-
* сообщение и только потом пишем в хранилище. Запись до запуска оставила бы
|
|
247
|
-
* задачу в «В работе» без сессии, если запуск упадёт.
|
|
224
|
+
* Launch or resume task execution.
|
|
248
225
|
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
226
|
+
* Order of operations: acquire agent, wait for idle, send prompt, then update store.
|
|
227
|
+
* Prevents leaving orphaned in-progress cards if agent launch fails.
|
|
228
|
+
* Column moves from backlog to in-progress only on initial launch. Resumed tasks
|
|
229
|
+
* retain their current column (e.g. review).
|
|
252
230
|
*
|
|
253
|
-
* @param {string} [text]
|
|
231
|
+
* @param {string} [text] user message; defaults to standard template if empty
|
|
254
232
|
* @returns {{sessionId: string, mode: string, task: object}}
|
|
255
233
|
*/
|
|
256
234
|
/**
|
|
257
|
-
*
|
|
235
|
+
* Mode explanation for task continuation: opened, resumed, or created.
|
|
258
236
|
*
|
|
259
|
-
*
|
|
260
|
-
* из них произошла: в первом случае переписка на месте, во втором её больше
|
|
261
|
-
* нет.
|
|
262
|
-
*
|
|
263
|
-
* @returns {'opened'|'resumed'|'created'} как в obtainAgent
|
|
237
|
+
* @returns {'opened'|'resumed'|'created'} matching obtainAgent mode
|
|
264
238
|
*/
|
|
265
239
|
export function revivalKind(mode) {
|
|
266
240
|
return mode === 'opened' || mode === 'resumed' ? mode : 'created'
|
|
267
241
|
}
|
|
268
242
|
|
|
269
243
|
/**
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
* Запустить работу и оставить задачу ничьей — значит показать её свободной,
|
|
273
|
-
* когда над ней уже работают. Чужое назначение не трогаем: задачу могли отдать
|
|
274
|
-
* другому осознанно.
|
|
244
|
+
* Claim task upon launch if currently unassigned.
|
|
245
|
+
* Does not overwrite existing assignee.
|
|
275
246
|
*
|
|
276
|
-
* @returns {string}
|
|
247
|
+
* @returns {string} assignee login if claimed; otherwise empty string
|
|
277
248
|
*/
|
|
278
249
|
export function claimOnStart({ store, task, login }) {
|
|
279
250
|
const who = typeof login === 'string' ? login.trim() : ''
|
|
@@ -285,7 +256,7 @@ export function claimOnStart({ store, task, login }) {
|
|
|
285
256
|
fromCol: task.column,
|
|
286
257
|
toCol: task.column,
|
|
287
258
|
source: 'manual',
|
|
288
|
-
detail:
|
|
259
|
+
detail: `assigned: ${who} (claimed on launch)`,
|
|
289
260
|
})
|
|
290
261
|
return who
|
|
291
262
|
}
|
|
@@ -296,11 +267,11 @@ export async function runTask({
|
|
|
296
267
|
agentPreset, permission, permissions, claimAs,
|
|
297
268
|
gitRunner, workspaceRegistry, resume = false,
|
|
298
269
|
}) {
|
|
299
|
-
//
|
|
270
|
+
// Concurrency limit check (#214)
|
|
300
271
|
const maxConcurrent = typeof config?.maxConcurrentSessions === 'number' ? config.maxConcurrentSessions : 3
|
|
301
272
|
if (maxConcurrent > 0) {
|
|
302
273
|
const activeCount = countActiveSessions({ store, agents })
|
|
303
|
-
//
|
|
274
|
+
// Check active session slots if task is not already running
|
|
304
275
|
const isAlreadyRunning = task.sessionId && agents?.get?.(task.sessionId)?.status === 'running'
|
|
305
276
|
if (!isAlreadyRunning && activeCount >= maxConcurrent) {
|
|
306
277
|
const now = Date.now()
|
|
@@ -310,7 +281,7 @@ export async function runTask({
|
|
|
310
281
|
fromCol: task.column,
|
|
311
282
|
toCol: task.column,
|
|
312
283
|
source: 'session',
|
|
313
|
-
detail:
|
|
284
|
+
detail: `concurrent session limit reached (${activeCount}/${maxConcurrent}), task queued`,
|
|
314
285
|
})
|
|
315
286
|
return { mode: 'queued', queued: true, taskId: task.id, activeCount, maxConcurrent }
|
|
316
287
|
}
|
|
@@ -318,9 +289,11 @@ export async function runTask({
|
|
|
318
289
|
if (config?.permissionGateEnabled) {
|
|
319
290
|
const perm = permission || task.permission || 'read-only'
|
|
320
291
|
const defaultPerm = config?.sessionDefaultPermission || 'read-only'
|
|
321
|
-
|
|
292
|
+
const permRank = PERMISSION_RANKS[perm] ?? Infinity
|
|
293
|
+
const defaultRank = PERMISSION_RANKS[defaultPerm] ?? 0
|
|
294
|
+
if (permRank > defaultRank) {
|
|
322
295
|
if (!task.permissionConfirmedAt || task.permissionConfirmedAt === 0) {
|
|
323
|
-
const err = new Error('
|
|
296
|
+
const err = new Error('Permission confirmation required')
|
|
324
297
|
err.key = 'permission-unconfirmed'
|
|
325
298
|
err.status = 403
|
|
326
299
|
throw err
|
|
@@ -337,7 +310,7 @@ export async function runTask({
|
|
|
337
310
|
store.updateTask(task.id, { queuedAt: 0 })
|
|
338
311
|
}
|
|
339
312
|
|
|
340
|
-
//
|
|
313
|
+
// Record run in task execution history
|
|
341
314
|
if (typeof store.recordTaskRun === 'function') {
|
|
342
315
|
store.recordTaskRun(task.id, {
|
|
343
316
|
id: randomUUID(),
|
|
@@ -350,8 +323,7 @@ export async function runTask({
|
|
|
350
323
|
|
|
351
324
|
await taken.agent.whenIdle()
|
|
352
325
|
taken.agent.followup(createMessage({
|
|
353
|
-
//
|
|
354
|
-
// (`content.some(...)`), и строка роняет ход целиком.
|
|
326
|
+
// Content must be an array of blocks for core message schema.
|
|
355
327
|
content: [{ type: 'text', text: messageFor(task, config, text, { resume, mode: taken.mode }) }],
|
|
356
328
|
source: { kind: 'dsh-kanban', plugin: 'dsh-kanban', form: resume ? 'task-resume' : 'task-start' },
|
|
357
329
|
}))
|
|
@@ -367,8 +339,7 @@ export async function runTask({
|
|
|
367
339
|
...(taken.workspaceId ? { workspaceId: taken.workspaceId } : {}),
|
|
368
340
|
}
|
|
369
341
|
store.updateTask(task.id, patch)
|
|
370
|
-
//
|
|
371
|
-
// остаётся чужой.
|
|
342
|
+
// Claim task on launch if unassigned.
|
|
372
343
|
claimOnStart({ store, task: before, login: claimAs })
|
|
373
344
|
|
|
374
345
|
if (resume) {
|
|
@@ -377,30 +348,29 @@ export async function runTask({
|
|
|
377
348
|
fromCol: before.column,
|
|
378
349
|
toCol: before.column,
|
|
379
350
|
source: 'session',
|
|
380
|
-
detail:
|
|
351
|
+
detail: `continuing task execution (session ${taken.sessionId})`,
|
|
381
352
|
})
|
|
382
353
|
}
|
|
383
354
|
|
|
384
|
-
//
|
|
355
|
+
// Record worktree isolation transition in task log.
|
|
385
356
|
if (taken.worktreeCreated) {
|
|
386
357
|
store.addTransition({
|
|
387
358
|
taskId: task.id,
|
|
388
359
|
fromCol: before.column,
|
|
389
360
|
toCol: before.column,
|
|
390
361
|
source: 'session',
|
|
391
|
-
detail:
|
|
362
|
+
detail: `isolation: created worktree ${taken.worktree} (${taken.branch})`,
|
|
392
363
|
})
|
|
393
364
|
}
|
|
394
365
|
|
|
395
|
-
//
|
|
396
|
-
// поймёт, куда делась переписка.
|
|
366
|
+
// Log notice if unable to resume previous session and new session had to be spawned.
|
|
397
367
|
if (taken.mode === 'created' && before.sessionId) {
|
|
398
368
|
store.addTransition({
|
|
399
369
|
taskId: task.id,
|
|
400
370
|
fromCol: before.column,
|
|
401
371
|
toCol: before.column,
|
|
402
372
|
source: 'session',
|
|
403
|
-
detail:
|
|
373
|
+
detail: `previous session not resumed, spawned new: ${taken.sessionId}`,
|
|
404
374
|
})
|
|
405
375
|
}
|
|
406
376
|
|
|
@@ -412,33 +382,33 @@ export async function runTask({
|
|
|
412
382
|
fromCol: before.column,
|
|
413
383
|
toCol: 'in-progress',
|
|
414
384
|
source: 'session',
|
|
415
|
-
detail:
|
|
385
|
+
detail: `session ${taken.sessionId}, model ${model}`,
|
|
416
386
|
})
|
|
417
387
|
}
|
|
418
388
|
|
|
419
389
|
return { sessionId: taken.sessionId, mode: revivalKind(taken.mode), task: current }
|
|
420
390
|
}
|
|
421
391
|
|
|
422
|
-
/**
|
|
392
|
+
/** Build prompt text: custom user message if provided, otherwise standard template. */
|
|
423
393
|
export function messageFor(task, config, text, options = {}) {
|
|
424
394
|
const own = typeof text === 'string' ? text.trim() : ''
|
|
425
395
|
let base = own !== '' ? own : buildStartMessage(task, config)
|
|
426
396
|
let resumePreamble = ''
|
|
427
397
|
if (options.resume || options.mode === 'resumed') {
|
|
428
|
-
const wt = task?.worktree ? `
|
|
429
|
-
const br = task?.branch ? `
|
|
430
|
-
resumePreamble = `### ↻
|
|
398
|
+
const wt = task?.worktree ? ` in existing worktree ${task.worktree}` : ''
|
|
399
|
+
const br = task?.branch ? ` on branch ${task.branch}` : ''
|
|
400
|
+
resumePreamble = `### ↻ Resumed task execution\nContinuing work${wt}${br}. Preserve existing changes and proceed from the current state.\n\n`
|
|
431
401
|
}
|
|
432
402
|
let qualityNotes = ''
|
|
433
403
|
if (task?.rejectReason) {
|
|
434
|
-
qualityNotes += `### ⚠️
|
|
404
|
+
qualityNotes += `### ⚠️ Notice: Task returned for rework\nReview notes (rejection reason):\n${task.rejectReason}\n\n`
|
|
435
405
|
}
|
|
436
406
|
if (Array.isArray(task?.checklist) && task.checklist.length > 0) {
|
|
437
407
|
const uncompleted = task.checklist.filter((i) => i.required !== false && !i.completed)
|
|
438
408
|
if (uncompleted.length > 0) {
|
|
439
|
-
qualityNotes += `###
|
|
409
|
+
qualityNotes += `### Definition of Done (DoD):\n`
|
|
440
410
|
+ uncompleted.map((item, idx) => `${idx + 1}. [ ] ${item.text}`).join('\n')
|
|
441
|
-
+ `\
|
|
411
|
+
+ `\nUse the board_checklist(item, evidence) tool to record completed items.\n\n`
|
|
442
412
|
}
|
|
443
413
|
}
|
|
444
414
|
if (qualityNotes) {
|
|
@@ -463,9 +433,7 @@ export function messageFor(task, config, text, options = {}) {
|
|
|
463
433
|
}
|
|
464
434
|
|
|
465
435
|
/**
|
|
466
|
-
*
|
|
467
|
-
* умолчанию из харнесса. Если не выбрано ни там, ни там — внятный отказ, а не
|
|
468
|
-
* падение.
|
|
436
|
+
* Resolve provider and model for launch: user choice first, then harness default.
|
|
469
437
|
*/
|
|
470
438
|
export function resolveModel({ requested, fallback }) {
|
|
471
439
|
const provider = requested?.provider || fallback?.provider
|
|
@@ -475,32 +443,16 @@ export function resolveModel({ requested, fallback }) {
|
|
|
475
443
|
}
|
|
476
444
|
|
|
477
445
|
/**
|
|
478
|
-
*
|
|
479
|
-
*
|
|
480
|
-
* Номер и общее число — не украшение. По воркфлоу агент после задачи делает
|
|
481
|
-
* cleanup: удаляет ветку, worktree, закрывает issue. Получив первую из пяти и
|
|
482
|
-
* не зная про остальные, он честно объявит работу законченной и приберёт за
|
|
483
|
-
* собой — а следующая придёт в уже прибранный чат.
|
|
484
|
-
*
|
|
485
|
-
* Номер превращает пять отдельных работ в одну из пяти частей.
|
|
446
|
+
* Message prompt for an individual task in a batch execution.
|
|
486
447
|
*/
|
|
487
448
|
export function buildQueuedMessage(task, config, at, total) {
|
|
488
449
|
const body = buildStartMessage(task, config)
|
|
489
|
-
return total > 1 ? joinBlocks([
|
|
450
|
+
return total > 1 ? joinBlocks([`Task ${at} of ${total}.`, body]) : body
|
|
490
451
|
}
|
|
491
452
|
|
|
492
453
|
/**
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
* «Разом» не бывает: пачка всегда идёт по одной. Решение владельца, и правило
|
|
496
|
-
* лучше выбора — одним переключателем меньше и одним путём в коде меньше.
|
|
497
|
-
*
|
|
498
|
-
* В работу уезжает ТОЛЬКО первая. Остальные остаются на своих местах с
|
|
499
|
-
* отметкой очереди: десять карточек в «В работе», из которых делается одна, —
|
|
500
|
-
* это ложь, и предел колонки от неё теряет смысл.
|
|
501
|
-
*
|
|
502
|
-
* Порядок важен: сперва агент и сообщения, потом хранилище. Запись до запуска
|
|
503
|
-
* оставила бы карточки с идентификатором сессии, которой нет.
|
|
454
|
+
* Launch a batch of tasks within a single session, processed sequentially.
|
|
455
|
+
* Only the first task transitions to 'in-progress'; remaining tasks are queued.
|
|
504
456
|
*/
|
|
505
457
|
export async function runBatch({
|
|
506
458
|
agents, store, tasks, config, provider, model,
|
|
@@ -523,35 +475,34 @@ export async function runBatch({
|
|
|
523
475
|
}))
|
|
524
476
|
|
|
525
477
|
if (own !== '' && list.length === 1) {
|
|
526
|
-
send(own, 'task-start')
|
|
478
|
+
await send(own, 'task-start')
|
|
527
479
|
} else {
|
|
528
|
-
|
|
480
|
+
for (let at = 0; at < list.length; at++) {
|
|
481
|
+
await send(buildQueuedMessage(list[at], config, at + 1, list.length), 'batch-queued')
|
|
482
|
+
}
|
|
529
483
|
}
|
|
530
484
|
|
|
531
485
|
const started = []
|
|
532
486
|
list.forEach((task, at) => {
|
|
533
487
|
const before = store.getTask(task.id)
|
|
534
488
|
if (before === undefined) return
|
|
535
|
-
//
|
|
536
|
-
// постановки идёт с шагом, чтобы порядок очереди совпал с порядком выбора
|
|
537
|
-
// даже когда всё случилось в одну миллисекунду.
|
|
489
|
+
// First task moves to in-progress; remainder are queued with staggered queuedAt.
|
|
538
490
|
const patch = { sessionId: taken.sessionId, provider, model, queuedAt: at === 0 ? 0 : now + at }
|
|
539
491
|
store.updateTask(task.id, patch)
|
|
540
|
-
//
|
|
541
|
-
// ответственного выглядят свободными.
|
|
492
|
+
// Claim all tasks in batch under launching user.
|
|
542
493
|
claimOnStart({ store, task: before, login: claimAs })
|
|
543
494
|
if (at === 0 && before.column === 'backlog') {
|
|
544
495
|
store.moveTask(task.id, { column: 'in-progress' })
|
|
545
496
|
store.addTransition({
|
|
546
497
|
taskId: task.id, fromCol: before.column, toCol: 'in-progress', source: 'session',
|
|
547
498
|
detail: list.length > 1
|
|
548
|
-
?
|
|
549
|
-
:
|
|
499
|
+
? `session ${taken.sessionId} for ${list.length} tasks, model ${model}`
|
|
500
|
+
: `session ${taken.sessionId}, model ${model}`,
|
|
550
501
|
})
|
|
551
502
|
} else if (at > 0) {
|
|
552
503
|
store.addTransition({
|
|
553
504
|
taskId: task.id, fromCol: before.column, toCol: before.column, source: 'session',
|
|
554
|
-
detail:
|
|
505
|
+
detail: `in session queue ${taken.sessionId}, ${at + 1} of ${list.length}`,
|
|
555
506
|
})
|
|
556
507
|
}
|
|
557
508
|
started.push(task.id)
|
|
@@ -561,11 +512,7 @@ export async function runBatch({
|
|
|
561
512
|
}
|
|
562
513
|
|
|
563
514
|
/**
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
* Своей очереди не заводим: у ядра она есть. `followup` кладёт сообщение, а
|
|
567
|
-
* агент берёт его на ближайшей границе шага — то есть закончив текущее.
|
|
568
|
-
* Вторая очередь рядом с настоящей разошлась бы с ней при первой же заминке.
|
|
515
|
+
* Queue a task to an active session using core followup queue.
|
|
569
516
|
*/
|
|
570
517
|
export async function queueTask({
|
|
571
518
|
agents, store, task, sessionId, config, createMessage, text, now = Date.now(),
|
|
@@ -576,8 +523,7 @@ export async function queueTask({
|
|
|
576
523
|
|
|
577
524
|
let agent
|
|
578
525
|
try { agent = agents.get(id) } catch { agent = undefined }
|
|
579
|
-
//
|
|
580
|
-
// останется привязанной к тому, чего нет.
|
|
526
|
+
// Reject queueing to dead or non-existent session.
|
|
581
527
|
if (agent === undefined) return { error: 'session-not-live', status: 409 }
|
|
582
528
|
|
|
583
529
|
const own = typeof text === 'string' ? text.trim() : ''
|
|
@@ -590,20 +536,14 @@ export async function queueTask({
|
|
|
590
536
|
store.updateTask(task.id, { sessionId: id, queuedAt: now })
|
|
591
537
|
store.addTransition({
|
|
592
538
|
taskId: task.id, fromCol: before.column, toCol: before.column, source: 'session',
|
|
593
|
-
detail:
|
|
539
|
+
detail: `queued for session ${id}`,
|
|
594
540
|
})
|
|
595
541
|
return { task: store.getTask(task.id), sessionId: id }
|
|
596
542
|
}
|
|
597
543
|
|
|
598
544
|
/**
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
* Обязательная пара к постановке: если поставить можно, а передумать нельзя,
|
|
602
|
-
* человек перестанет пользоваться и постановкой.
|
|
603
|
-
*
|
|
604
|
-
* Сообщение из очереди ядра не выдернуть — оно уже отдано. Поэтому снимаем
|
|
605
|
-
* привязку и честно говорим об этом в журнале: агент может дойти до задачи и
|
|
606
|
-
* увидеть карточку, которая его больше не ждёт.
|
|
545
|
+
* Remove task from queue.
|
|
546
|
+
* Clears queue association in store and logs note that message was already sent to agent.
|
|
607
547
|
*/
|
|
608
548
|
export function unqueueTask({ store, task }) {
|
|
609
549
|
if (task === undefined) return { error: 'task-not-found', status: 404 }
|
|
@@ -613,16 +553,13 @@ export function unqueueTask({ store, task }) {
|
|
|
613
553
|
store.updateTask(task.id, { queuedAt: 0, sessionId: null })
|
|
614
554
|
store.addTransition({
|
|
615
555
|
taskId: task.id, fromCol: task.column, toCol: task.column, source: 'manual',
|
|
616
|
-
detail: '
|
|
556
|
+
detail: 'removed from queue; message already sent to agent',
|
|
617
557
|
})
|
|
618
558
|
return { task: store.getTask(task.id) }
|
|
619
559
|
}
|
|
620
560
|
|
|
621
561
|
/**
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
* Сессия без живого агента в список не попадает — предлагать её значило бы
|
|
625
|
-
* предлагать очередь, из которой никто ничего не возьмёт.
|
|
562
|
+
* Query active sessions eligible for queueing.
|
|
626
563
|
*/
|
|
627
564
|
export function countActiveSessions({ store, agents }) {
|
|
628
565
|
const sessions = liveSessions({ store, agents })
|
|
@@ -638,7 +575,7 @@ export function liveSessions({ store, agents }) {
|
|
|
638
575
|
let live
|
|
639
576
|
try { live = agents.get(id) } catch { live = undefined }
|
|
640
577
|
if (live === undefined) continue
|
|
641
|
-
//
|
|
578
|
+
// Exclude child subagent sessions (#215)
|
|
642
579
|
if (live.origin === 'subagent' || live.parentId !== undefined || live.meta?.origin === 'subagent' || live.isSubagent) {
|
|
643
580
|
continue
|
|
644
581
|
}
|
|
@@ -652,7 +589,7 @@ export function liveSessions({ store, agents }) {
|
|
|
652
589
|
}
|
|
653
590
|
|
|
654
591
|
/**
|
|
655
|
-
*
|
|
592
|
+
* Auto-dispatch next queued task when a concurrency slot opens (#214).
|
|
656
593
|
*/
|
|
657
594
|
export async function dispatchNextQueuedTask({
|
|
658
595
|
agents, store, config, mintSessionId, createMessage, cwdOf, logger,
|
|
@@ -688,7 +625,7 @@ export async function dispatchNextQueuedTask({
|
|
|
688
625
|
gitRunner,
|
|
689
626
|
})
|
|
690
627
|
} catch (err) {
|
|
691
|
-
logger?.warn?.(`dsh-kanban:
|
|
628
|
+
logger?.warn?.(`dsh-kanban: failed to start queued task ${nextTask.id}: ${err?.message}`)
|
|
692
629
|
return null
|
|
693
630
|
}
|
|
694
631
|
}
|