@goodandready/dsh-kanban 0.2.19 → 0.2.20
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 +29 -54
- package/lib/cron.js +13 -13
- package/lib/events.js +1 -1
- package/lib/filters.js +58 -82
- package/lib/git-provider.js +23 -23
- package/lib/gitea.js +51 -109
- package/lib/github.js +27 -31
- package/lib/import.js +46 -67
- package/lib/index.js +66 -132
- package/lib/intake.js +18 -26
- package/lib/launcher.js +100 -167
- 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 +25 -26
- package/lib/plan.js +23 -45
- package/lib/power.js +2 -2
- package/lib/progress-dump.js +17 -17
- package/lib/routes.js +86 -145
- package/lib/store.js +59 -77
- package/lib/sync.js +56 -98
- package/lib/time.js +8 -17
- package/lib/transitions.js +36 -57
- package/lib/webhook.js +16 -23
- package/lib/workflow-bridge.js +57 -57
- package/lib/worktree.js +58 -58
- 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
|
-
* было. Поэтому новую сессию поднимаем только когда прежней взять неоткуда.
|
|
130
|
-
*
|
|
131
|
-
* Возобновление не проверяем заранее отдельным вопросом «сохранена ли сессия»:
|
|
132
|
-
* такого вопроса у ядра нет, а `resume` сам скажет отказом. Пробуем и ловим.
|
|
115
|
+
* Retrieve agent for task: active, resumed, or newly spawned.
|
|
133
116
|
*
|
|
134
|
-
*
|
|
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
|
-
*
|
|
224
|
+
* Launch or resume task execution.
|
|
244
225
|
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
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).
|
|
248
230
|
*
|
|
249
|
-
*
|
|
250
|
-
* которой нажали «Продолжить», обязана остаться в «Ревью»: продолжение — это
|
|
251
|
-
* не откат к началу.
|
|
252
|
-
*
|
|
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
|
-
*
|
|
258
|
-
*
|
|
259
|
-
* «Продолжил» и «начал заново» — разные вещи, и человек обязан видеть, какая
|
|
260
|
-
* из них произошла: в первом случае переписка на месте, во втором её больше
|
|
261
|
-
* нет.
|
|
235
|
+
* Mode explanation for task continuation: opened, resumed, or created.
|
|
262
236
|
*
|
|
263
|
-
* @returns {'opened'|'resumed'|'created'}
|
|
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
|
-
*
|
|
244
|
+
* Claim task upon launch if currently unassigned.
|
|
245
|
+
* Does not overwrite existing assignee.
|
|
271
246
|
*
|
|
272
|
-
*
|
|
273
|
-
* когда над ней уже работают. Чужое назначение не трогаем: задачу могли отдать
|
|
274
|
-
* другому осознанно.
|
|
275
|
-
*
|
|
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
|
}
|
|
@@ -320,7 +291,7 @@ export async function runTask({
|
|
|
320
291
|
const defaultPerm = config?.sessionDefaultPermission || 'read-only'
|
|
321
292
|
if ((PERMISSION_RANKS[perm] ?? 0) > (PERMISSION_RANKS[defaultPerm] ?? 0)) {
|
|
322
293
|
if (!task.permissionConfirmedAt || task.permissionConfirmedAt === 0) {
|
|
323
|
-
const err = new Error('
|
|
294
|
+
const err = new Error('Permission confirmation required')
|
|
324
295
|
err.key = 'permission-unconfirmed'
|
|
325
296
|
err.status = 403
|
|
326
297
|
throw err
|
|
@@ -337,7 +308,7 @@ export async function runTask({
|
|
|
337
308
|
store.updateTask(task.id, { queuedAt: 0 })
|
|
338
309
|
}
|
|
339
310
|
|
|
340
|
-
//
|
|
311
|
+
// Record run in task execution history
|
|
341
312
|
if (typeof store.recordTaskRun === 'function') {
|
|
342
313
|
store.recordTaskRun(task.id, {
|
|
343
314
|
id: randomUUID(),
|
|
@@ -350,8 +321,7 @@ export async function runTask({
|
|
|
350
321
|
|
|
351
322
|
await taken.agent.whenIdle()
|
|
352
323
|
taken.agent.followup(createMessage({
|
|
353
|
-
//
|
|
354
|
-
// (`content.some(...)`), и строка роняет ход целиком.
|
|
324
|
+
// Content must be an array of blocks for core message schema.
|
|
355
325
|
content: [{ type: 'text', text: messageFor(task, config, text, { resume, mode: taken.mode }) }],
|
|
356
326
|
source: { kind: 'dsh-kanban', plugin: 'dsh-kanban', form: resume ? 'task-resume' : 'task-start' },
|
|
357
327
|
}))
|
|
@@ -367,8 +337,7 @@ export async function runTask({
|
|
|
367
337
|
...(taken.workspaceId ? { workspaceId: taken.workspaceId } : {}),
|
|
368
338
|
}
|
|
369
339
|
store.updateTask(task.id, patch)
|
|
370
|
-
//
|
|
371
|
-
// остаётся чужой.
|
|
340
|
+
// Claim task on launch if unassigned.
|
|
372
341
|
claimOnStart({ store, task: before, login: claimAs })
|
|
373
342
|
|
|
374
343
|
if (resume) {
|
|
@@ -377,30 +346,29 @@ export async function runTask({
|
|
|
377
346
|
fromCol: before.column,
|
|
378
347
|
toCol: before.column,
|
|
379
348
|
source: 'session',
|
|
380
|
-
detail:
|
|
349
|
+
detail: `continuing task execution (session ${taken.sessionId})`,
|
|
381
350
|
})
|
|
382
351
|
}
|
|
383
352
|
|
|
384
|
-
//
|
|
353
|
+
// Record worktree isolation transition in task log.
|
|
385
354
|
if (taken.worktreeCreated) {
|
|
386
355
|
store.addTransition({
|
|
387
356
|
taskId: task.id,
|
|
388
357
|
fromCol: before.column,
|
|
389
358
|
toCol: before.column,
|
|
390
359
|
source: 'session',
|
|
391
|
-
detail:
|
|
360
|
+
detail: `isolation: created worktree ${taken.worktree} (${taken.branch})`,
|
|
392
361
|
})
|
|
393
362
|
}
|
|
394
363
|
|
|
395
|
-
//
|
|
396
|
-
// поймёт, куда делась переписка.
|
|
364
|
+
// Log notice if unable to resume previous session and new session had to be spawned.
|
|
397
365
|
if (taken.mode === 'created' && before.sessionId) {
|
|
398
366
|
store.addTransition({
|
|
399
367
|
taskId: task.id,
|
|
400
368
|
fromCol: before.column,
|
|
401
369
|
toCol: before.column,
|
|
402
370
|
source: 'session',
|
|
403
|
-
detail:
|
|
371
|
+
detail: `previous session not resumed, spawned new: ${taken.sessionId}`,
|
|
404
372
|
})
|
|
405
373
|
}
|
|
406
374
|
|
|
@@ -412,33 +380,33 @@ export async function runTask({
|
|
|
412
380
|
fromCol: before.column,
|
|
413
381
|
toCol: 'in-progress',
|
|
414
382
|
source: 'session',
|
|
415
|
-
detail:
|
|
383
|
+
detail: `session ${taken.sessionId}, model ${model}`,
|
|
416
384
|
})
|
|
417
385
|
}
|
|
418
386
|
|
|
419
387
|
return { sessionId: taken.sessionId, mode: revivalKind(taken.mode), task: current }
|
|
420
388
|
}
|
|
421
389
|
|
|
422
|
-
/**
|
|
390
|
+
/** Build prompt text: custom user message if provided, otherwise standard template. */
|
|
423
391
|
export function messageFor(task, config, text, options = {}) {
|
|
424
392
|
const own = typeof text === 'string' ? text.trim() : ''
|
|
425
393
|
let base = own !== '' ? own : buildStartMessage(task, config)
|
|
426
394
|
let resumePreamble = ''
|
|
427
395
|
if (options.resume || options.mode === 'resumed') {
|
|
428
|
-
const wt = task?.worktree ? `
|
|
429
|
-
const br = task?.branch ? `
|
|
430
|
-
resumePreamble = `### ↻
|
|
396
|
+
const wt = task?.worktree ? ` in existing worktree ${task.worktree}` : ''
|
|
397
|
+
const br = task?.branch ? ` on branch ${task.branch}` : ''
|
|
398
|
+
resumePreamble = `### ↻ Resumed task execution\nContinuing work${wt}${br}. Preserve existing changes and proceed from the current state.\n\n`
|
|
431
399
|
}
|
|
432
400
|
let qualityNotes = ''
|
|
433
401
|
if (task?.rejectReason) {
|
|
434
|
-
qualityNotes += `### ⚠️
|
|
402
|
+
qualityNotes += `### ⚠️ Notice: Task returned for rework\nReview notes (rejection reason):\n${task.rejectReason}\n\n`
|
|
435
403
|
}
|
|
436
404
|
if (Array.isArray(task?.checklist) && task.checklist.length > 0) {
|
|
437
405
|
const uncompleted = task.checklist.filter((i) => i.required !== false && !i.completed)
|
|
438
406
|
if (uncompleted.length > 0) {
|
|
439
|
-
qualityNotes += `###
|
|
407
|
+
qualityNotes += `### Definition of Done (DoD):\n`
|
|
440
408
|
+ uncompleted.map((item, idx) => `${idx + 1}. [ ] ${item.text}`).join('\n')
|
|
441
|
-
+ `\
|
|
409
|
+
+ `\nUse the board_checklist(item, evidence) tool to record completed items.\n\n`
|
|
442
410
|
}
|
|
443
411
|
}
|
|
444
412
|
if (qualityNotes) {
|
|
@@ -463,9 +431,7 @@ export function messageFor(task, config, text, options = {}) {
|
|
|
463
431
|
}
|
|
464
432
|
|
|
465
433
|
/**
|
|
466
|
-
*
|
|
467
|
-
* умолчанию из харнесса. Если не выбрано ни там, ни там — внятный отказ, а не
|
|
468
|
-
* падение.
|
|
434
|
+
* Resolve provider and model for launch: user choice first, then harness default.
|
|
469
435
|
*/
|
|
470
436
|
export function resolveModel({ requested, fallback }) {
|
|
471
437
|
const provider = requested?.provider || fallback?.provider
|
|
@@ -475,32 +441,16 @@ export function resolveModel({ requested, fallback }) {
|
|
|
475
441
|
}
|
|
476
442
|
|
|
477
443
|
/**
|
|
478
|
-
*
|
|
479
|
-
*
|
|
480
|
-
* Номер и общее число — не украшение. По воркфлоу агент после задачи делает
|
|
481
|
-
* cleanup: удаляет ветку, worktree, закрывает issue. Получив первую из пяти и
|
|
482
|
-
* не зная про остальные, он честно объявит работу законченной и приберёт за
|
|
483
|
-
* собой — а следующая придёт в уже прибранный чат.
|
|
484
|
-
*
|
|
485
|
-
* Номер превращает пять отдельных работ в одну из пяти частей.
|
|
444
|
+
* Message prompt for an individual task in a batch execution.
|
|
486
445
|
*/
|
|
487
446
|
export function buildQueuedMessage(task, config, at, total) {
|
|
488
447
|
const body = buildStartMessage(task, config)
|
|
489
|
-
return total > 1 ? joinBlocks([
|
|
448
|
+
return total > 1 ? joinBlocks([`Task ${at} of ${total}.`, body]) : body
|
|
490
449
|
}
|
|
491
450
|
|
|
492
451
|
/**
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
* «Разом» не бывает: пачка всегда идёт по одной. Решение владельца, и правило
|
|
496
|
-
* лучше выбора — одним переключателем меньше и одним путём в коде меньше.
|
|
497
|
-
*
|
|
498
|
-
* В работу уезжает ТОЛЬКО первая. Остальные остаются на своих местах с
|
|
499
|
-
* отметкой очереди: десять карточек в «В работе», из которых делается одна, —
|
|
500
|
-
* это ложь, и предел колонки от неё теряет смысл.
|
|
501
|
-
*
|
|
502
|
-
* Порядок важен: сперва агент и сообщения, потом хранилище. Запись до запуска
|
|
503
|
-
* оставила бы карточки с идентификатором сессии, которой нет.
|
|
452
|
+
* Launch a batch of tasks within a single session, processed sequentially.
|
|
453
|
+
* Only the first task transitions to 'in-progress'; remaining tasks are queued.
|
|
504
454
|
*/
|
|
505
455
|
export async function runBatch({
|
|
506
456
|
agents, store, tasks, config, provider, model,
|
|
@@ -532,26 +482,23 @@ export async function runBatch({
|
|
|
532
482
|
list.forEach((task, at) => {
|
|
533
483
|
const before = store.getTask(task.id)
|
|
534
484
|
if (before === undefined) return
|
|
535
|
-
//
|
|
536
|
-
// постановки идёт с шагом, чтобы порядок очереди совпал с порядком выбора
|
|
537
|
-
// даже когда всё случилось в одну миллисекунду.
|
|
485
|
+
// First task moves to in-progress; remainder are queued with staggered queuedAt.
|
|
538
486
|
const patch = { sessionId: taken.sessionId, provider, model, queuedAt: at === 0 ? 0 : now + at }
|
|
539
487
|
store.updateTask(task.id, patch)
|
|
540
|
-
//
|
|
541
|
-
// ответственного выглядят свободными.
|
|
488
|
+
// Claim all tasks in batch under launching user.
|
|
542
489
|
claimOnStart({ store, task: before, login: claimAs })
|
|
543
490
|
if (at === 0 && before.column === 'backlog') {
|
|
544
491
|
store.moveTask(task.id, { column: 'in-progress' })
|
|
545
492
|
store.addTransition({
|
|
546
493
|
taskId: task.id, fromCol: before.column, toCol: 'in-progress', source: 'session',
|
|
547
494
|
detail: list.length > 1
|
|
548
|
-
?
|
|
549
|
-
:
|
|
495
|
+
? `session ${taken.sessionId} for ${list.length} tasks, model ${model}`
|
|
496
|
+
: `session ${taken.sessionId}, model ${model}`,
|
|
550
497
|
})
|
|
551
498
|
} else if (at > 0) {
|
|
552
499
|
store.addTransition({
|
|
553
500
|
taskId: task.id, fromCol: before.column, toCol: before.column, source: 'session',
|
|
554
|
-
detail:
|
|
501
|
+
detail: `in session queue ${taken.sessionId}, ${at + 1} of ${list.length}`,
|
|
555
502
|
})
|
|
556
503
|
}
|
|
557
504
|
started.push(task.id)
|
|
@@ -561,11 +508,7 @@ export async function runBatch({
|
|
|
561
508
|
}
|
|
562
509
|
|
|
563
510
|
/**
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
* Своей очереди не заводим: у ядра она есть. `followup` кладёт сообщение, а
|
|
567
|
-
* агент берёт его на ближайшей границе шага — то есть закончив текущее.
|
|
568
|
-
* Вторая очередь рядом с настоящей разошлась бы с ней при первой же заминке.
|
|
511
|
+
* Queue a task to an active session using core followup queue.
|
|
569
512
|
*/
|
|
570
513
|
export async function queueTask({
|
|
571
514
|
agents, store, task, sessionId, config, createMessage, text, now = Date.now(),
|
|
@@ -576,8 +519,7 @@ export async function queueTask({
|
|
|
576
519
|
|
|
577
520
|
let agent
|
|
578
521
|
try { agent = agents.get(id) } catch { agent = undefined }
|
|
579
|
-
//
|
|
580
|
-
// останется привязанной к тому, чего нет.
|
|
522
|
+
// Reject queueing to dead or non-existent session.
|
|
581
523
|
if (agent === undefined) return { error: 'session-not-live', status: 409 }
|
|
582
524
|
|
|
583
525
|
const own = typeof text === 'string' ? text.trim() : ''
|
|
@@ -590,20 +532,14 @@ export async function queueTask({
|
|
|
590
532
|
store.updateTask(task.id, { sessionId: id, queuedAt: now })
|
|
591
533
|
store.addTransition({
|
|
592
534
|
taskId: task.id, fromCol: before.column, toCol: before.column, source: 'session',
|
|
593
|
-
detail:
|
|
535
|
+
detail: `queued for session ${id}`,
|
|
594
536
|
})
|
|
595
537
|
return { task: store.getTask(task.id), sessionId: id }
|
|
596
538
|
}
|
|
597
539
|
|
|
598
540
|
/**
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
* Обязательная пара к постановке: если поставить можно, а передумать нельзя,
|
|
602
|
-
* человек перестанет пользоваться и постановкой.
|
|
603
|
-
*
|
|
604
|
-
* Сообщение из очереди ядра не выдернуть — оно уже отдано. Поэтому снимаем
|
|
605
|
-
* привязку и честно говорим об этом в журнале: агент может дойти до задачи и
|
|
606
|
-
* увидеть карточку, которая его больше не ждёт.
|
|
541
|
+
* Remove task from queue.
|
|
542
|
+
* Clears queue association in store and logs note that message was already sent to agent.
|
|
607
543
|
*/
|
|
608
544
|
export function unqueueTask({ store, task }) {
|
|
609
545
|
if (task === undefined) return { error: 'task-not-found', status: 404 }
|
|
@@ -613,16 +549,13 @@ export function unqueueTask({ store, task }) {
|
|
|
613
549
|
store.updateTask(task.id, { queuedAt: 0, sessionId: null })
|
|
614
550
|
store.addTransition({
|
|
615
551
|
taskId: task.id, fromCol: task.column, toCol: task.column, source: 'manual',
|
|
616
|
-
detail: '
|
|
552
|
+
detail: 'removed from queue; message already sent to agent',
|
|
617
553
|
})
|
|
618
554
|
return { task: store.getTask(task.id) }
|
|
619
555
|
}
|
|
620
556
|
|
|
621
557
|
/**
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
* Сессия без живого агента в список не попадает — предлагать её значило бы
|
|
625
|
-
* предлагать очередь, из которой никто ничего не возьмёт.
|
|
558
|
+
* Query active sessions eligible for queueing.
|
|
626
559
|
*/
|
|
627
560
|
export function countActiveSessions({ store, agents }) {
|
|
628
561
|
const sessions = liveSessions({ store, agents })
|
|
@@ -638,7 +571,7 @@ export function liveSessions({ store, agents }) {
|
|
|
638
571
|
let live
|
|
639
572
|
try { live = agents.get(id) } catch { live = undefined }
|
|
640
573
|
if (live === undefined) continue
|
|
641
|
-
//
|
|
574
|
+
// Exclude child subagent sessions (#215)
|
|
642
575
|
if (live.origin === 'subagent' || live.parentId !== undefined || live.meta?.origin === 'subagent' || live.isSubagent) {
|
|
643
576
|
continue
|
|
644
577
|
}
|
|
@@ -652,7 +585,7 @@ export function liveSessions({ store, agents }) {
|
|
|
652
585
|
}
|
|
653
586
|
|
|
654
587
|
/**
|
|
655
|
-
*
|
|
588
|
+
* Auto-dispatch next queued task when a concurrency slot opens (#214).
|
|
656
589
|
*/
|
|
657
590
|
export async function dispatchNextQueuedTask({
|
|
658
591
|
agents, store, config, mintSessionId, createMessage, cwdOf, logger,
|
|
@@ -688,7 +621,7 @@ export async function dispatchNextQueuedTask({
|
|
|
688
621
|
gitRunner,
|
|
689
622
|
})
|
|
690
623
|
} catch (err) {
|
|
691
|
-
logger?.warn?.(`dsh-kanban:
|
|
624
|
+
logger?.warn?.(`dsh-kanban: failed to start queued task ${nextTask.id}: ${err?.message}`)
|
|
692
625
|
return null
|
|
693
626
|
}
|
|
694
627
|
}
|