@goodandready/dsh-kanban 0.1.24 → 0.1.26
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 +17 -0
- package/docs/design/DESIGN.md +2 -0
- package/lib/client.js +162 -2
- package/lib/config.js +16 -0
- package/lib/cron.js +154 -0
- package/lib/index.js +187 -6
- package/lib/launcher.js +116 -7
- package/lib/lifecycle.js +34 -5
- package/lib/power.js +157 -0
- package/lib/progress-dump.js +134 -0
- package/lib/routes.js +22 -1
- package/lib/store.js +65 -7
- package/lib/worktree.js +339 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -23,12 +23,15 @@ import {
|
|
|
23
23
|
buildBoard, applyMove, createTask, updateTask, deleteTask, appendNote, setArchived, listArchive,
|
|
24
24
|
taskBody,
|
|
25
25
|
taskLog, taskBySession, exportBoard, importBoard, assignTask, boardMetrics,
|
|
26
|
+
confirmPermission,
|
|
26
27
|
} from './routes.js'
|
|
27
28
|
import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
|
|
28
29
|
import {
|
|
29
30
|
runTask, resolveModel, buildStartMessage,
|
|
30
31
|
runBatch, queueTask, unqueueTask, liveSessions,
|
|
32
|
+
PERMISSION_RANKS,
|
|
31
33
|
} from './launcher.js'
|
|
34
|
+
import { cleanupTaskWorktree, checkWorktreeDirty } from './worktree.js'
|
|
32
35
|
import { dispatchMove, MOVE_DETAIL, stopWork, STOP_DETAIL } from './commands.js'
|
|
33
36
|
import { createGiteaClient } from './gitea.js'
|
|
34
37
|
import { syncAll, createSyncState, intakeAll, archiveOverdue } from './sync.js'
|
|
@@ -37,6 +40,9 @@ import { handleSessionEvent } from './lifecycle.js'
|
|
|
37
40
|
import { boardMoveDefinition, boardPlanDefinition } from './board-tool.js'
|
|
38
41
|
import { verifySignature, parseEvent } from './webhook.js'
|
|
39
42
|
import { planOutbound, planAssign, createOutbox } from './outbound.js'
|
|
43
|
+
import { computeNextRun } from './cron.js'
|
|
44
|
+
import { PowerInhibitor } from './power.js'
|
|
45
|
+
import { formatProgressDump, parseProgressDump } from './progress-dump.js'
|
|
40
46
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
41
47
|
|
|
42
48
|
export { COLUMN_ORDER, wipLimitField, withDefaults } from './config.js'
|
|
@@ -196,17 +202,140 @@ export function apply(ctx, config) {
|
|
|
196
202
|
return () => { try { store.close() } catch { /* уже закрыта */ } }
|
|
197
203
|
}, 'dsh-kanban: хранилище задач')
|
|
198
204
|
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
205
|
+
// Кроссплатформенный ингибитор сна системы во время работы задач
|
|
206
|
+
const powerInhibitor = new PowerInhibitor({
|
|
207
|
+
platform: process.platform,
|
|
208
|
+
pid: process.pid,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
const syncPower = () => {
|
|
212
|
+
try {
|
|
213
|
+
const cfg = getConfig()
|
|
214
|
+
powerInhibitor.setEnabled(cfg.preventIdleSleep === true)
|
|
215
|
+
if (store) {
|
|
216
|
+
const activeCount = store.countInColumn('main', 'in-progress') + store.countInColumn('simple', 'in-progress')
|
|
217
|
+
powerInhibitor.setReasons(activeCount)
|
|
218
|
+
}
|
|
219
|
+
} catch { /* игнорируем */ }
|
|
220
|
+
}
|
|
221
|
+
rearmers.add(syncPower)
|
|
222
|
+
ctx.effect(() => {
|
|
223
|
+
syncPower()
|
|
224
|
+
return () => { powerInhibitor.destroy() }
|
|
225
|
+
}, 'dsh-kanban: ингибитор сна')
|
|
226
|
+
|
|
227
|
+
// Инъекция секции доски в системный промпт агента
|
|
228
|
+
let disposePromptSection
|
|
229
|
+
const syncPromptSection = () => {
|
|
230
|
+
if (disposePromptSection) {
|
|
231
|
+
try { disposePromptSection() } catch {}
|
|
232
|
+
disposePromptSection = undefined
|
|
233
|
+
}
|
|
234
|
+
const cfg = getConfig()
|
|
235
|
+
const sp = ctx.get('systemPrompt')
|
|
236
|
+
if (cfg.announceToAgent !== false && sp && typeof sp.section === 'function') {
|
|
237
|
+
try {
|
|
238
|
+
disposePromptSection = sp.section({
|
|
239
|
+
name: 'plugin:dsh-kanban',
|
|
240
|
+
order: 200,
|
|
241
|
+
text: 'В системе развернута доска задач dsh-kanban. При работе над задачами вы можете перемещать карточки инструментом board_move и вести чек-лист плана через board_plan. При исчерпании контекста или завершении подэтапа сформируйте блок <<<PROGRESSDUMP\\nЦель: ...\\nПрогресс: ...\\nСледующие шаги: ...\\n>>>PROGRESSDUMP для передачи эстафеты следующей сессии.',
|
|
242
|
+
})
|
|
243
|
+
} catch (err) {
|
|
244
|
+
ctx.logger?.warn?.(`dsh-kanban: не удалось зарегистрировать секцию промпта: ${err?.message}`)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
rearmers.add(syncPromptSection)
|
|
249
|
+
ctx.effect(() => {
|
|
250
|
+
syncPromptSection()
|
|
251
|
+
return () => { if (disposePromptSection) { try { disposePromptSection() } catch {} } }
|
|
252
|
+
}, 'dsh-kanban: секция системного промпта')
|
|
253
|
+
|
|
254
|
+
// Признак ожидания, исходы сессий и PROGRESSDUMP по событиям сессии.
|
|
202
255
|
ctx.effect(() => ctx.on('session/event', (session, event) => {
|
|
203
256
|
try {
|
|
204
|
-
|
|
257
|
+
const text = event?.content?.[0]?.text ?? event?.text ?? ''
|
|
258
|
+
handleSessionEvent({
|
|
259
|
+
store,
|
|
260
|
+
sessionId: String(session?.id ?? ''),
|
|
261
|
+
type: event?.type,
|
|
262
|
+
text,
|
|
263
|
+
error: event?.error,
|
|
264
|
+
})
|
|
265
|
+
syncPower()
|
|
205
266
|
} catch (error) {
|
|
206
267
|
ctx.logger?.warn?.(`dsh-kanban: событие сессии не обработано: ${error?.message}`)
|
|
207
268
|
}
|
|
208
269
|
}), 'dsh-kanban: события сессии')
|
|
209
270
|
|
|
271
|
+
// Фоновый шедулер cron для задач
|
|
272
|
+
ctx.effect(() => {
|
|
273
|
+
let cronTimer
|
|
274
|
+
const armCron = () => {
|
|
275
|
+
clearInterval(cronTimer)
|
|
276
|
+
cronTimer = undefined
|
|
277
|
+
if (getConfig().cronEnabled !== true) return
|
|
278
|
+
cronTimer = setInterval(async () => {
|
|
279
|
+
try {
|
|
280
|
+
if (!store) return
|
|
281
|
+
const now = Date.now()
|
|
282
|
+
const dueTasks = store.listDueTasks(now)
|
|
283
|
+
for (const task of dueTasks) {
|
|
284
|
+
if (task.column === 'in-progress') {
|
|
285
|
+
const next = computeNextRun(task.cron, now)
|
|
286
|
+
store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
|
|
287
|
+
continue
|
|
288
|
+
}
|
|
289
|
+
if (getConfig().permissionGateEnabled) {
|
|
290
|
+
const perm = task.permission || 'read-only'
|
|
291
|
+
const defaultPerm = getConfig().sessionDefaultPermission || 'read-only'
|
|
292
|
+
if ((PERMISSION_RANKS[perm] ?? 0) > (PERMISSION_RANKS[defaultPerm] ?? 0) && !task.permissionConfirmedAt) {
|
|
293
|
+
const next = computeNextRun(task.cron, now)
|
|
294
|
+
store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
|
|
295
|
+
store.addTransition({
|
|
296
|
+
taskId: task.id,
|
|
297
|
+
fromCol: task.column,
|
|
298
|
+
toCol: task.column,
|
|
299
|
+
source: 'tool',
|
|
300
|
+
detail: 'cron: запуск отклонён (требуется подтверждение повышенных прав)',
|
|
301
|
+
})
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const picked = resolveModel({
|
|
306
|
+
requested: { model: task.model, provider: task.provider },
|
|
307
|
+
fallback: ctx.get('agentDefaultModel')?.currentSelection?.(),
|
|
308
|
+
})
|
|
309
|
+
if (picked.model) {
|
|
310
|
+
await runTask({
|
|
311
|
+
agents: ctx.agents,
|
|
312
|
+
store,
|
|
313
|
+
task,
|
|
314
|
+
config: getConfig(),
|
|
315
|
+
provider: picked.provider,
|
|
316
|
+
model: picked.model,
|
|
317
|
+
mintSessionId: () => SessionId(`kanban-${task.id}-${randomUUID()}`),
|
|
318
|
+
createMessage: createUserMessage,
|
|
319
|
+
logger: ctx.logger,
|
|
320
|
+
workspaceRegistry: ctx.get('workspaceRegistry'),
|
|
321
|
+
claimAs: 'cron',
|
|
322
|
+
})
|
|
323
|
+
syncPower()
|
|
324
|
+
}
|
|
325
|
+
const next = computeNextRun(task.cron, now)
|
|
326
|
+
store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
|
|
327
|
+
}
|
|
328
|
+
} catch (err) {
|
|
329
|
+
ctx.logger?.warn?.(`dsh-kanban: ошибка шедулера cron: ${err?.message}`)
|
|
330
|
+
}
|
|
331
|
+
}, 30_000)
|
|
332
|
+
if (typeof cronTimer.unref === 'function') cronTimer.unref()
|
|
333
|
+
}
|
|
334
|
+
armCron()
|
|
335
|
+
rearmers.add(armCron)
|
|
336
|
+
return () => { rearmers.delete(armCron); clearInterval(cronTimer) }
|
|
337
|
+
}, 'dsh-kanban: шедулер cron')
|
|
338
|
+
|
|
210
339
|
// Периодическая сверка с Gitea. Ноль в настройках отключает её целиком, а
|
|
211
340
|
// перекрытие запусков не допускается: медленный инстанс иначе копил бы
|
|
212
341
|
// очередь сверок вместо одной идущей.
|
|
@@ -645,7 +774,7 @@ export function apply(ctx, config) {
|
|
|
645
774
|
fallback: ctx.get('agentDefaultModel')?.currentSelection?.(),
|
|
646
775
|
})
|
|
647
776
|
if (picked.error) { writeJson(res, picked.status, { error: picked.error }); return }
|
|
648
|
-
|
|
777
|
+
const started = await runTask({
|
|
649
778
|
agents: ctx.agents,
|
|
650
779
|
store,
|
|
651
780
|
task,
|
|
@@ -661,8 +790,11 @@ export function apply(ctx, config) {
|
|
|
661
790
|
agentPreset: typeof input.agentPreset === 'string' ? input.agentPreset : undefined,
|
|
662
791
|
permission: typeof input.permission === 'string' ? input.permission : undefined,
|
|
663
792
|
permissions: ctx.get('permissionPresets'),
|
|
793
|
+
workspaceRegistry: ctx.get('workspaceRegistry'),
|
|
664
794
|
claimAs: await whoAmI(),
|
|
665
|
-
})
|
|
795
|
+
})
|
|
796
|
+
syncPower()
|
|
797
|
+
reply(res, started)
|
|
666
798
|
} catch (error) {
|
|
667
799
|
ctx.logger?.warn?.(`dsh-kanban: запуск задачи не удался: ${error?.message}`)
|
|
668
800
|
// Отказ по уровню доступа называется своим именем: «не удалось» на
|
|
@@ -698,15 +830,64 @@ export function apply(ctx, config) {
|
|
|
698
830
|
if (out?.task !== undefined) {
|
|
699
831
|
queueOutbound(id, out.task.column)
|
|
700
832
|
if (acted !== undefined) out.command = acted
|
|
833
|
+
if (input.column === 'cleanup' && was?.worktree) {
|
|
834
|
+
const dirty = await checkWorktreeDirty({ worktreePath: was.worktree })
|
|
835
|
+
if (dirty.dirty) {
|
|
836
|
+
out.worktreeWarning = 'worktree-dirty'
|
|
837
|
+
out.dirtyFiles = dirty.files
|
|
838
|
+
}
|
|
839
|
+
}
|
|
701
840
|
}
|
|
702
841
|
reply(res, out)
|
|
703
842
|
return
|
|
704
843
|
}
|
|
844
|
+
if (req.method === 'POST' && action === 'cleanup-worktree') {
|
|
845
|
+
const task = store.getTask(id)
|
|
846
|
+
if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
|
|
847
|
+
const out = await cleanupTaskWorktree({
|
|
848
|
+
store,
|
|
849
|
+
task,
|
|
850
|
+
workspaceRegistry: ctx.get('workspaceRegistry'),
|
|
851
|
+
force: input?.force === true,
|
|
852
|
+
})
|
|
853
|
+
reply(res, out)
|
|
854
|
+
return
|
|
855
|
+
}
|
|
856
|
+
if (req.method === 'GET' && action === 'worktree-status') {
|
|
857
|
+
const task = store.getTask(id)
|
|
858
|
+
if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
|
|
859
|
+
const dirtyCheck = await checkWorktreeDirty({ worktreePath: task.worktree })
|
|
860
|
+
reply(res, {
|
|
861
|
+
worktree: task.worktree || '',
|
|
862
|
+
branch: task.branch || '',
|
|
863
|
+
workspaceId: task.workspaceId || '',
|
|
864
|
+
dirty: dirtyCheck.dirty,
|
|
865
|
+
files: dirtyCheck.files,
|
|
866
|
+
})
|
|
867
|
+
return
|
|
868
|
+
}
|
|
869
|
+
if (req.method === 'POST' && action === 'confirm-permission') {
|
|
870
|
+
reply(res, confirmPermission({ store, id }))
|
|
871
|
+
return
|
|
872
|
+
}
|
|
873
|
+
if (req.method === 'POST' && action === 'progress-dump') {
|
|
874
|
+
const task = store.getTask(id)
|
|
875
|
+
if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
|
|
876
|
+
const formatted = typeof input === 'string' ? input : formatProgressDump(input)
|
|
877
|
+
const updated = store.updateTask(id, { progressDump: formatted })
|
|
878
|
+
reply(res, { task: updated })
|
|
879
|
+
return
|
|
880
|
+
}
|
|
705
881
|
if (req.method === 'POST' && action === 'stop') {
|
|
706
882
|
const task = store.getTask(id)
|
|
707
883
|
if (task === undefined) { writeJson(res, 404, { error: 'task-not-found' }); return }
|
|
708
884
|
const out = stopWork({ agents: ctx.agents, task, logger: ctx.logger })
|
|
709
885
|
if (out.acted === 'stopped') {
|
|
886
|
+
const latestRun = Array.isArray(task.runs) ? task.runs[0] : undefined
|
|
887
|
+
if (latestRun && !latestRun.endedAt && typeof store.updateTaskRun === 'function') {
|
|
888
|
+
store.updateTaskRun(task.id, latestRun.id, { endedAt: Date.now(), result: 'cancelled' })
|
|
889
|
+
}
|
|
890
|
+
syncPower()
|
|
710
891
|
// Остановка — событие, и в журнале ей место наравне с переносом:
|
|
711
892
|
// иначе прерванная работа выглядит просто оборвавшейся сама.
|
|
712
893
|
store.addTransition({
|
package/lib/launcher.js
CHANGED
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
// приходят параметрами. Так модуль проверяется без харнесса и без сети.
|
|
14
14
|
|
|
15
15
|
import { isAbsolute, join, resolve, sep } from 'node:path'
|
|
16
|
+
import { randomUUID } from 'node:crypto'
|
|
17
|
+
import { createTaskWorktree, registerDshWorkspace } from './worktree.js'
|
|
18
|
+
import { formatHandoverPreamble, parseProgressDump } from './progress-dump.js'
|
|
19
|
+
|
|
20
|
+
export const PERMISSION_RANKS = { 'read-only': 0, 'workspace-write': 1, 'danger-full-access': 2 }
|
|
16
21
|
|
|
17
22
|
/**
|
|
18
23
|
* Хвост своей задачи — УСЛОВИЕ, а не приказ.
|
|
@@ -146,7 +151,7 @@ export function applyPermission({ permissions, agent, name }) {
|
|
|
146
151
|
|
|
147
152
|
export async function obtainAgent({
|
|
148
153
|
agents, task, config, provider, model, mintSessionId, cwdOf, logger,
|
|
149
|
-
agentPreset, permission, permissions,
|
|
154
|
+
agentPreset, permission, permissions, gitRunner, workspaceRegistry,
|
|
150
155
|
}) {
|
|
151
156
|
const known = typeof task?.sessionId === 'string' && task.sessionId !== '' ? task.sessionId : undefined
|
|
152
157
|
|
|
@@ -165,12 +170,51 @@ export async function obtainAgent({
|
|
|
165
170
|
}
|
|
166
171
|
}
|
|
167
172
|
|
|
168
|
-
|
|
173
|
+
let cwd = resolveCwd(task, config, cwdOf)
|
|
174
|
+
let worktreePath = task?.worktree || undefined
|
|
175
|
+
let branchName = task?.branch || undefined
|
|
176
|
+
let workspaceId = task?.workspaceId || undefined
|
|
177
|
+
let worktreeCreated = false
|
|
178
|
+
|
|
179
|
+
if (config?.worktreeIsolation !== false && task?.repo) {
|
|
180
|
+
try {
|
|
181
|
+
const created = await createTaskWorktree({
|
|
182
|
+
task,
|
|
183
|
+
repoDir: cwd,
|
|
184
|
+
config,
|
|
185
|
+
gitRunner,
|
|
186
|
+
cwdOf,
|
|
187
|
+
})
|
|
188
|
+
if (created) {
|
|
189
|
+
worktreePath = created.worktreePath
|
|
190
|
+
branchName = created.branchName
|
|
191
|
+
worktreeCreated = created.created === true
|
|
192
|
+
cwd = created.worktreePath
|
|
193
|
+
|
|
194
|
+
if (workspaceRegistry) {
|
|
195
|
+
workspaceId = await registerDshWorkspace({
|
|
196
|
+
workspaceRegistry,
|
|
197
|
+
worktreePath,
|
|
198
|
+
title: `${task.owner ? task.owner + '/' : ''}${task.repo}#${task.issueNumber || task.id.slice(0, 8)}: ${task.title}`,
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} catch (err) {
|
|
203
|
+
logger?.warn?.(`dsh-kanban: не удалось создать worktree для ${task.id}: ${err.message}`)
|
|
204
|
+
}
|
|
205
|
+
} else if (worktreePath) {
|
|
206
|
+
cwd = worktreePath
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const meta = { cwd }
|
|
210
|
+
if (typeof agentPreset === 'string' && agentPreset !== '') meta.agentPreset = agentPreset
|
|
211
|
+
if (workspaceId) meta.workspaceId = workspaceId
|
|
212
|
+
|
|
169
213
|
const handle = await agents.create({
|
|
170
214
|
sessionId: mintSessionId(),
|
|
171
215
|
// Профиль — факт создания сессии: у идущей его не сменить, поэтому он
|
|
172
216
|
// едет здесь, а не отдельным вызовом после.
|
|
173
|
-
meta
|
|
217
|
+
meta,
|
|
174
218
|
agentOptions: { provider, model },
|
|
175
219
|
})
|
|
176
220
|
const access = applyPermission({ permissions, agent: handle.agent, name: permission })
|
|
@@ -183,7 +227,15 @@ export async function obtainAgent({
|
|
|
183
227
|
error.status = 409
|
|
184
228
|
throw error
|
|
185
229
|
}
|
|
186
|
-
return {
|
|
230
|
+
return {
|
|
231
|
+
agent: handle.agent,
|
|
232
|
+
sessionId: handle.agent.session.id,
|
|
233
|
+
mode: 'created',
|
|
234
|
+
worktree: worktreePath,
|
|
235
|
+
branch: branchName,
|
|
236
|
+
workspaceId,
|
|
237
|
+
worktreeCreated,
|
|
238
|
+
}
|
|
187
239
|
}
|
|
188
240
|
|
|
189
241
|
/**
|
|
@@ -241,12 +293,37 @@ export async function runTask({
|
|
|
241
293
|
agents, store, task, config, provider, model,
|
|
242
294
|
mintSessionId, createMessage, cwdOf, logger, text,
|
|
243
295
|
agentPreset, permission, permissions, claimAs,
|
|
296
|
+
gitRunner, workspaceRegistry,
|
|
244
297
|
}) {
|
|
298
|
+
if (config?.permissionGateEnabled) {
|
|
299
|
+
const perm = permission || task.permission || 'read-only'
|
|
300
|
+
const defaultPerm = config?.sessionDefaultPermission || 'read-only'
|
|
301
|
+
if ((PERMISSION_RANKS[perm] ?? 0) > (PERMISSION_RANKS[defaultPerm] ?? 0)) {
|
|
302
|
+
if (!task.permissionConfirmedAt || task.permissionConfirmedAt === 0) {
|
|
303
|
+
const err = new Error('Требуется подтверждение прав')
|
|
304
|
+
err.key = 'permission-unconfirmed'
|
|
305
|
+
err.status = 403
|
|
306
|
+
throw err
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
245
311
|
const taken = await obtainAgent({
|
|
246
312
|
agents, task, config, provider, model, mintSessionId, cwdOf, logger,
|
|
247
|
-
agentPreset, permission, permissions,
|
|
313
|
+
agentPreset, permission, permissions, gitRunner, workspaceRegistry,
|
|
248
314
|
})
|
|
249
315
|
|
|
316
|
+
// Фиксируем запуск в истории задачи
|
|
317
|
+
if (typeof store.recordTaskRun === 'function') {
|
|
318
|
+
store.recordTaskRun(task.id, {
|
|
319
|
+
id: randomUUID(),
|
|
320
|
+
sessionId: taken.sessionId,
|
|
321
|
+
startedAt: Date.now(),
|
|
322
|
+
model,
|
|
323
|
+
provider,
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
250
327
|
await taken.agent.whenIdle()
|
|
251
328
|
taken.agent.followup(createMessage({
|
|
252
329
|
// Содержимое — МАССИВ блоков, а не строка: ядро перебирает его как список
|
|
@@ -256,12 +333,31 @@ export async function runTask({
|
|
|
256
333
|
}))
|
|
257
334
|
|
|
258
335
|
const before = store.getTask(task.id)
|
|
259
|
-
const patch = {
|
|
336
|
+
const patch = {
|
|
337
|
+
sessionId: taken.sessionId,
|
|
338
|
+
provider,
|
|
339
|
+
model,
|
|
340
|
+
...(permission ? { permission } : {}),
|
|
341
|
+
...(taken.worktree ? { worktree: taken.worktree } : {}),
|
|
342
|
+
...(taken.branch ? { branch: taken.branch } : {}),
|
|
343
|
+
...(taken.workspaceId ? { workspaceId: taken.workspaceId } : {}),
|
|
344
|
+
}
|
|
260
345
|
store.updateTask(task.id, patch)
|
|
261
346
|
// Запустил — значит отвечаешь: свободная задача становится твоей, чужая
|
|
262
347
|
// остаётся чужой.
|
|
263
348
|
claimOnStart({ store, task: before, login: claimAs })
|
|
264
349
|
|
|
350
|
+
// Изоляция сессии в отдельном воркдереве фиксируется в журнале.
|
|
351
|
+
if (taken.worktreeCreated) {
|
|
352
|
+
store.addTransition({
|
|
353
|
+
taskId: task.id,
|
|
354
|
+
fromCol: before.column,
|
|
355
|
+
toCol: before.column,
|
|
356
|
+
source: 'session',
|
|
357
|
+
detail: `изоляция: создано воркдерево ${taken.worktree} (${taken.branch})`,
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
|
|
265
361
|
// Подмена чата не должна быть молчаливой: человек вернётся к задаче и не
|
|
266
362
|
// поймёт, куда делась переписка.
|
|
267
363
|
if (taken.mode === 'created' && before.sessionId) {
|
|
@@ -292,7 +388,20 @@ export async function runTask({
|
|
|
292
388
|
/** Текст сообщения: человеческий, если он есть, иначе встроенная заготовка. */
|
|
293
389
|
function messageFor(task, config, text) {
|
|
294
390
|
const own = typeof text === 'string' ? text.trim() : ''
|
|
295
|
-
|
|
391
|
+
const base = own !== '' ? own : buildStartMessage(task, config)
|
|
392
|
+
if (config?.progressDumpEnabled && task.progressDump) {
|
|
393
|
+
let parsed
|
|
394
|
+
try {
|
|
395
|
+
parsed = typeof task.progressDump === 'string' && task.progressDump.startsWith('{')
|
|
396
|
+
? JSON.parse(task.progressDump)
|
|
397
|
+
: parseProgressDump(task.progressDump)?.dump
|
|
398
|
+
} catch {
|
|
399
|
+
parsed = parseProgressDump(task.progressDump)?.dump
|
|
400
|
+
}
|
|
401
|
+
const preamble = formatHandoverPreamble(parsed)
|
|
402
|
+
if (preamble) return `${preamble}${base}`
|
|
403
|
+
}
|
|
404
|
+
return base
|
|
296
405
|
}
|
|
297
406
|
|
|
298
407
|
/**
|
package/lib/lifecycle.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// делать с очередным событием.
|
|
10
10
|
|
|
11
11
|
import { waitingFromEvent } from './transitions.js'
|
|
12
|
+
import { parseProgressDump, formatProgressDump } from './progress-dump.js'
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Обработать событие сессии.
|
|
@@ -17,14 +18,42 @@ import { waitingFromEvent } from './transitions.js'
|
|
|
17
18
|
* большинство — для них здесь ничего не происходит, и это штатный ход, а не
|
|
18
19
|
* пропуск.
|
|
19
20
|
*
|
|
20
|
-
* @returns {{taskId: string, waiting
|
|
21
|
+
* @returns {{taskId: string, waiting?: boolean}|undefined} что изменилось
|
|
21
22
|
*/
|
|
22
|
-
export function handleSessionEvent({ store, sessionId, type }) {
|
|
23
|
-
const next = waitingFromEvent(type)
|
|
24
|
-
if (next === undefined) return undefined
|
|
25
|
-
|
|
23
|
+
export function handleSessionEvent({ store, sessionId, type, text, error }) {
|
|
26
24
|
const task = store.findTaskBySession(sessionId)
|
|
27
25
|
if (task === undefined) return undefined
|
|
26
|
+
|
|
27
|
+
// Если в ответе пришёл PROGRESSDUMP — обновляем срез в задаче
|
|
28
|
+
if (typeof text === 'string' && text.includes('<<<PROGRESSDUMP')) {
|
|
29
|
+
const parsed = parseProgressDump(text)
|
|
30
|
+
if (parsed.ok) {
|
|
31
|
+
store.updateTask(task.id, { progressDump: formatProgressDump(parsed.dump) })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Обновляем исход запуска в истории runs
|
|
36
|
+
if (type === 'turn/end' || type === 'turn/completed') {
|
|
37
|
+
const latestRun = Array.isArray(task.runs) ? task.runs[0] : undefined
|
|
38
|
+
if (latestRun && latestRun.sessionId === sessionId && !latestRun.endedAt && typeof store.updateTaskRun === 'function') {
|
|
39
|
+
store.updateTaskRun(task.id, latestRun.id, {
|
|
40
|
+
endedAt: Date.now(),
|
|
41
|
+
result: 'succeeded',
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
} else if (type === 'turn/error' || type === 'session/error') {
|
|
45
|
+
const latestRun = Array.isArray(task.runs) ? task.runs[0] : undefined
|
|
46
|
+
if (latestRun && latestRun.sessionId === sessionId && !latestRun.endedAt && typeof store.updateTaskRun === 'function') {
|
|
47
|
+
store.updateTaskRun(task.id, latestRun.id, {
|
|
48
|
+
endedAt: Date.now(),
|
|
49
|
+
result: 'failed',
|
|
50
|
+
error: typeof error === 'string' ? error : error?.message || 'error',
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const next = waitingFromEvent(type)
|
|
56
|
+
if (next === undefined) return undefined
|
|
28
57
|
if (task.waiting === next) return undefined
|
|
29
58
|
|
|
30
59
|
store.updateTask(task.id, { waiting: next })
|
package/lib/power.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// dsh-kanban: кроссплатформенный ингибитор сна системы во время работы задач.
|
|
2
|
+
// Поддерживает Windows (PowerShell + Win32 API), Linux (systemd-inhibit) и macOS (caffeinate).
|
|
3
|
+
|
|
4
|
+
import { spawn as defaultSpawn } from 'node:child_process'
|
|
5
|
+
import { existsSync } from 'node:fs'
|
|
6
|
+
|
|
7
|
+
const WINDOWS_HELPER = [
|
|
8
|
+
'$source = @\'',
|
|
9
|
+
'using System;',
|
|
10
|
+
'using System.Runtime.InteropServices;',
|
|
11
|
+
'public static class DshExecutionState {',
|
|
12
|
+
' [DllImport("kernel32.dll", SetLastError = true)]',
|
|
13
|
+
' public static extern uint SetThreadExecutionState(uint flags);',
|
|
14
|
+
'}',
|
|
15
|
+
'\'@',
|
|
16
|
+
'Add-Type -TypeDefinition $source',
|
|
17
|
+
"$continuous = [Convert]::ToUInt32('80000000', 16)",
|
|
18
|
+
'$systemRequired = [uint32]0x00000001',
|
|
19
|
+
'try {',
|
|
20
|
+
' $result = [DshExecutionState]::SetThreadExecutionState($continuous -bor $systemRequired)',
|
|
21
|
+
' if ($result -eq 0) { throw \'SetThreadExecutionState failed\' }',
|
|
22
|
+
' [Console]::Out.WriteLine(\'READY\')',
|
|
23
|
+
' [Console]::Out.Flush()',
|
|
24
|
+
' while ([Console]::In.ReadLine() -ne $null) { }',
|
|
25
|
+
'} finally {',
|
|
26
|
+
' [void][DshExecutionState]::SetThreadExecutionState($continuous)',
|
|
27
|
+
'}',
|
|
28
|
+
].join('\n')
|
|
29
|
+
|
|
30
|
+
const LINUX_HELPER = "process.stdout.write('READY\\n'); process.stdin.resume();"
|
|
31
|
+
|
|
32
|
+
const LINUX_INHIBIT_PATHS = ['/usr/bin/systemd-inhibit', '/bin/systemd-inhibit']
|
|
33
|
+
|
|
34
|
+
export class PowerInhibitor {
|
|
35
|
+
constructor(options = {}) {
|
|
36
|
+
this.platform = options.platform ?? process.platform
|
|
37
|
+
this.pid = options.pid ?? process.pid
|
|
38
|
+
this.spawn = options.spawn ?? defaultSpawn
|
|
39
|
+
this.exists = options.exists ?? existsSync
|
|
40
|
+
this.enabled = false
|
|
41
|
+
this.reasons = 0
|
|
42
|
+
this.child = null
|
|
43
|
+
this.phase = 'disabled'
|
|
44
|
+
this.lastError = null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setEnabled(enabled) {
|
|
48
|
+
this.enabled = Boolean(enabled)
|
|
49
|
+
this.sync()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setReasons(count) {
|
|
53
|
+
this.reasons = Math.max(0, Number(count) || 0)
|
|
54
|
+
this.sync()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
acquire() {
|
|
58
|
+
this.reasons++
|
|
59
|
+
this.sync()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
release() {
|
|
63
|
+
this.reasons = Math.max(0, this.reasons - 1)
|
|
64
|
+
this.sync()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
sync() {
|
|
68
|
+
const shouldHold = this.enabled && this.reasons > 0
|
|
69
|
+
if (shouldHold && !this.child) {
|
|
70
|
+
this.startHelper()
|
|
71
|
+
} else if (!shouldHold && this.child) {
|
|
72
|
+
this.stopHelper()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
startHelper() {
|
|
77
|
+
this.stopHelper()
|
|
78
|
+
try {
|
|
79
|
+
if (this.platform === 'win32') {
|
|
80
|
+
const psPath = `${process.env.SystemRoot || 'C:\\Windows'}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
|
|
81
|
+
this.child = this.spawn(psPath, ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_HELPER], {
|
|
82
|
+
shell: false,
|
|
83
|
+
windowsHide: true,
|
|
84
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
85
|
+
})
|
|
86
|
+
} else if (this.platform === 'darwin') {
|
|
87
|
+
this.child = this.spawn('/usr/bin/caffeinate', ['-i', '-w', String(this.pid)], {
|
|
88
|
+
shell: false,
|
|
89
|
+
windowsHide: true,
|
|
90
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
91
|
+
})
|
|
92
|
+
} else if (this.platform === 'linux') {
|
|
93
|
+
const bin = LINUX_INHIBIT_PATHS.find((p) => this.exists(p))
|
|
94
|
+
if (!bin) {
|
|
95
|
+
this.phase = 'unsupported'
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
this.child = this.spawn(
|
|
99
|
+
bin,
|
|
100
|
+
['--what=idle', '--who=dsh-kanban', '--why=Task running', 'node', '-e', LINUX_HELPER],
|
|
101
|
+
{ shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] },
|
|
102
|
+
)
|
|
103
|
+
} else {
|
|
104
|
+
this.phase = 'unsupported'
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this.phase = 'running'
|
|
109
|
+
this.lastError = null
|
|
110
|
+
|
|
111
|
+
this.child.on('error', (err) => {
|
|
112
|
+
this.lastError = err?.message
|
|
113
|
+
this.phase = 'error'
|
|
114
|
+
this.child = null
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
this.child.on('exit', () => {
|
|
118
|
+
this.child = null
|
|
119
|
+
if (this.enabled && this.reasons > 0) {
|
|
120
|
+
this.phase = 'disabled'
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
} catch (err) {
|
|
124
|
+
this.lastError = err?.message
|
|
125
|
+
this.phase = 'error'
|
|
126
|
+
this.child = null
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
stopHelper() {
|
|
131
|
+
if (this.child) {
|
|
132
|
+
try {
|
|
133
|
+
if (this.child.stdin && !this.child.stdin.destroyed) {
|
|
134
|
+
this.child.stdin.end()
|
|
135
|
+
}
|
|
136
|
+
this.child.kill()
|
|
137
|
+
} catch {}
|
|
138
|
+
this.child = null
|
|
139
|
+
}
|
|
140
|
+
this.phase = 'disabled'
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
snapshot() {
|
|
144
|
+
return {
|
|
145
|
+
enabled: this.enabled,
|
|
146
|
+
reasons: this.reasons,
|
|
147
|
+
phase: this.phase,
|
|
148
|
+
lastError: this.lastError,
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
destroy() {
|
|
153
|
+
this.enabled = false
|
|
154
|
+
this.reasons = 0
|
|
155
|
+
this.stopHelper()
|
|
156
|
+
}
|
|
157
|
+
}
|