@goodandready/dsh-kanban 0.2.34 → 0.2.36
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 +8 -0
- package/lib/client.js +3800 -3587
- package/lib/config.js +10 -0
- package/lib/dependencies.js +175 -0
- package/lib/index.js +110 -1255
- package/lib/routes/board.js +286 -0
- package/lib/routes/security.js +274 -0
- package/lib/routes/task-actions.js +418 -0
- package/lib/routes/task-worktree.js +107 -0
- package/lib/routes/task.js +237 -0
- package/lib/routes.js +73 -1209
- package/lib/server/route-handlers.js +367 -0
- package/lib/server/services.js +225 -0
- package/lib/server/task-handler.js +569 -0
- package/lib/server/timers.js +204 -0
- package/lib/store.js +23 -2
- package/lib/time-machine.js +214 -0
- package/lib/watchdog.js +169 -0
- package/package.json +1 -1
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { computeNextRun } from '../cron.js'
|
|
2
|
+
import { PERMISSION_RANKS, resolveModel, runTask } from '../launcher.js'
|
|
3
|
+
import { createOutbox, planOutbound } from '../outbound.js'
|
|
4
|
+
import { createSyncState, syncAll, intakeAll, archiveOverdue } from '../sync.js'
|
|
5
|
+
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
6
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
7
|
+
import { randomUUID } from 'node:crypto'
|
|
8
|
+
|
|
9
|
+
export function registerKanbanTimers(ctx, deps) {
|
|
10
|
+
const {
|
|
11
|
+
store,
|
|
12
|
+
getConfig,
|
|
13
|
+
eventHub,
|
|
14
|
+
syncPower,
|
|
15
|
+
git,
|
|
16
|
+
gitea,
|
|
17
|
+
github,
|
|
18
|
+
rearmers,
|
|
19
|
+
watchdog,
|
|
20
|
+
} = deps
|
|
21
|
+
|
|
22
|
+
ctx.effect(() => {
|
|
23
|
+
let cronTimer
|
|
24
|
+
const armCron = () => {
|
|
25
|
+
clearInterval(cronTimer)
|
|
26
|
+
cronTimer = undefined
|
|
27
|
+
if (getConfig().cronEnabled !== true) return
|
|
28
|
+
cronTimer = setInterval(async () => {
|
|
29
|
+
try {
|
|
30
|
+
if (!store) return
|
|
31
|
+
const now = Date.now()
|
|
32
|
+
const dueTasks = store.listDueTasks(now)
|
|
33
|
+
for (const task of dueTasks) {
|
|
34
|
+
if (task.column === 'in-progress') {
|
|
35
|
+
const next = computeNextRun(task.cron, now)
|
|
36
|
+
store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
if (getConfig().permissionGateEnabled) {
|
|
40
|
+
const perm = task.permission || 'read-only'
|
|
41
|
+
const defaultPerm = getConfig().sessionDefaultPermission || 'read-only'
|
|
42
|
+
if ((PERMISSION_RANKS[perm] ?? 0) > (PERMISSION_RANKS[defaultPerm] ?? 0) && !task.permissionConfirmedAt) {
|
|
43
|
+
const next = computeNextRun(task.cron, now)
|
|
44
|
+
store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
|
|
45
|
+
store.addTransition({
|
|
46
|
+
taskId: task.id,
|
|
47
|
+
fromCol: task.column,
|
|
48
|
+
toCol: task.column,
|
|
49
|
+
source: 'tool',
|
|
50
|
+
detail: 'cron: run rejected (elevated permission required)',
|
|
51
|
+
})
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const picked = resolveModel({
|
|
56
|
+
requested: { model: task.model, provider: task.provider },
|
|
57
|
+
fallback: ctx.get('agentDefaultModel')?.currentSelection?.(),
|
|
58
|
+
})
|
|
59
|
+
if (picked.model) {
|
|
60
|
+
await runTask({
|
|
61
|
+
agents: ctx.agents,
|
|
62
|
+
store,
|
|
63
|
+
task,
|
|
64
|
+
config: getConfig(),
|
|
65
|
+
provider: picked.provider,
|
|
66
|
+
model: picked.model,
|
|
67
|
+
mintSessionId: () => SessionId(`kanban-${task.id}-${randomUUID()}`),
|
|
68
|
+
createMessage: createUserMessage,
|
|
69
|
+
logger: ctx.logger,
|
|
70
|
+
workspaceRegistry: ctx.get('workspaceRegistry'),
|
|
71
|
+
claimAs: 'cron',
|
|
72
|
+
})
|
|
73
|
+
syncPower()
|
|
74
|
+
}
|
|
75
|
+
const next = computeNextRun(task.cron, now)
|
|
76
|
+
store.updateTask(task.id, { lastRunAt: now, nextRunAt: next || 0 })
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
ctx.logger?.warn?.(`dsh-kanban: cron scheduler error: ${err?.message}`)
|
|
80
|
+
}
|
|
81
|
+
}, 30_000)
|
|
82
|
+
if (typeof cronTimer.unref === 'function') cronTimer.unref()
|
|
83
|
+
}
|
|
84
|
+
armCron()
|
|
85
|
+
rearmers.add(armCron)
|
|
86
|
+
return () => { rearmers.delete(armCron); clearInterval(cronTimer) }
|
|
87
|
+
}, 'dsh-kanban: cron scheduler')
|
|
88
|
+
|
|
89
|
+
const outbox = createOutbox({ gitea: git, get store() { return store }, logger: ctx.logger })
|
|
90
|
+
|
|
91
|
+
const queueOutbound = (taskId, column) => {
|
|
92
|
+
if (getConfig().pushToGitea !== true) return
|
|
93
|
+
const task = store.getTask(taskId)
|
|
94
|
+
if (task === undefined) return
|
|
95
|
+
const ops = planOutbound(task, column, getConfig())
|
|
96
|
+
if (ops.length > 0) outbox.push(taskId, ops)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let discoveredOwner
|
|
100
|
+
const defaultOwner = async () => {
|
|
101
|
+
const named = String(getConfig().giteaOwner ?? '').trim()
|
|
102
|
+
if (named !== '') return named
|
|
103
|
+
for (const task of store.listWatchable()) {
|
|
104
|
+
if (typeof task.owner === 'string' && task.owner !== '') return task.owner
|
|
105
|
+
}
|
|
106
|
+
if (discoveredOwner !== undefined) return discoveredOwner
|
|
107
|
+
const orgs = await gitea.listOrgs()
|
|
108
|
+
if (orgs.length === 1) { discoveredOwner = orgs[0]; return discoveredOwner }
|
|
109
|
+
return undefined
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const syncState = createSyncState()
|
|
113
|
+
|
|
114
|
+
let syncing = false
|
|
115
|
+
const runSync = async () => {
|
|
116
|
+
if (syncing) return { skipped: true }
|
|
117
|
+
syncing = true
|
|
118
|
+
syncState.started()
|
|
119
|
+
try {
|
|
120
|
+
const mode = String(getConfig()?.gitProvider || 'auto').toLowerCase()
|
|
121
|
+
const providersToSync = []
|
|
122
|
+
if (mode === 'github') {
|
|
123
|
+
providersToSync.push(github)
|
|
124
|
+
} else if (mode === 'gitea') {
|
|
125
|
+
providersToSync.push(gitea)
|
|
126
|
+
} else {
|
|
127
|
+
if (await gitea.isConfigured()) providersToSync.push(gitea)
|
|
128
|
+
if (await github.isConfigured()) providersToSync.push(github)
|
|
129
|
+
if (providersToSync.length === 0) providersToSync.push(gitea)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let taken = { added: 0, skipped: 0, failed: 0 }
|
|
133
|
+
for (const p of providersToSync) {
|
|
134
|
+
const owner = p === github
|
|
135
|
+
? (String(getConfig()?.githubOwner ?? '').trim() || await p.me().catch(() => ''))
|
|
136
|
+
: await defaultOwner()
|
|
137
|
+
const res = await intakeAll({
|
|
138
|
+
gitea: p, store, config: getConfig(), owner, logger: ctx.logger,
|
|
139
|
+
})
|
|
140
|
+
if (res && typeof res.added === 'number') {
|
|
141
|
+
taken.added = (taken.added || 0) + res.added
|
|
142
|
+
taken.skipped = (taken.skipped || 0) + (res.skipped || 0)
|
|
143
|
+
taken.failed = (taken.failed || 0) + (res.failed || 0)
|
|
144
|
+
}
|
|
145
|
+
if (res?.problem) taken.problem = res.problem
|
|
146
|
+
}
|
|
147
|
+
const out = await syncAll({ gitea: git, store, logger: ctx.logger })
|
|
148
|
+
const tidied = archiveOverdue({ store, config: getConfig() })
|
|
149
|
+
const pushed = await outbox.flush()
|
|
150
|
+
syncState.finished(out.problem === undefined && taken.problem !== undefined
|
|
151
|
+
? { ...out, problem: taken.problem }
|
|
152
|
+
: out)
|
|
153
|
+
return { ...out, pushed, taken, tidied }
|
|
154
|
+
} catch (error) {
|
|
155
|
+
syncState.failedToStart('sync', error?.message)
|
|
156
|
+
throw error
|
|
157
|
+
} finally {
|
|
158
|
+
syncing = false
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
ctx.effect(() => {
|
|
163
|
+
let timer
|
|
164
|
+
const arm = () => {
|
|
165
|
+
const seconds = Number(getConfig().syncIntervalSec) || 0
|
|
166
|
+
clearInterval(timer)
|
|
167
|
+
timer = undefined
|
|
168
|
+
if (seconds <= 0) return
|
|
169
|
+
timer = setInterval(() => { runSync().catch(() => {}) }, Math.max(seconds, 15) * 1000)
|
|
170
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
171
|
+
}
|
|
172
|
+
arm()
|
|
173
|
+
rearmers.add(arm)
|
|
174
|
+
return () => { rearmers.delete(arm); clearInterval(timer) }
|
|
175
|
+
}, 'dsh-kanban: sync timer')
|
|
176
|
+
|
|
177
|
+
ctx.effect(() => {
|
|
178
|
+
let watchdogTimer
|
|
179
|
+
const armWatchdog = () => {
|
|
180
|
+
clearInterval(watchdogTimer)
|
|
181
|
+
watchdogTimer = undefined
|
|
182
|
+
if (getConfig().watchdogEnabled === false || !watchdog) return
|
|
183
|
+
watchdogTimer = setInterval(() => {
|
|
184
|
+
try {
|
|
185
|
+
watchdog.checkTasks({
|
|
186
|
+
store,
|
|
187
|
+
agents: ctx.agents,
|
|
188
|
+
config: getConfig(),
|
|
189
|
+
eventHub,
|
|
190
|
+
logger: ctx.logger,
|
|
191
|
+
})
|
|
192
|
+
} catch (err) {
|
|
193
|
+
ctx.logger?.warn?.(`dsh-kanban: watchdog check error: ${err?.message}`)
|
|
194
|
+
}
|
|
195
|
+
}, 15000)
|
|
196
|
+
if (typeof watchdogTimer.unref === 'function') watchdogTimer.unref()
|
|
197
|
+
}
|
|
198
|
+
armWatchdog()
|
|
199
|
+
rearmers.add(armWatchdog)
|
|
200
|
+
return () => { rearmers.delete(armWatchdog); clearInterval(watchdogTimer) }
|
|
201
|
+
}, 'dsh-kanban: watchdog timer')
|
|
202
|
+
|
|
203
|
+
return { syncState, runSync, defaultOwner, queueOutbound }
|
|
204
|
+
}
|
package/lib/store.js
CHANGED
|
@@ -76,6 +76,11 @@ CREATE TABLE IF NOT EXISTS tasks (
|
|
|
76
76
|
forge TEXT NOT NULL DEFAULT '',
|
|
77
77
|
permissionConfirmedTier TEXT NOT NULL DEFAULT '',
|
|
78
78
|
queuedRequest TEXT NOT NULL DEFAULT '',
|
|
79
|
+
stalled INTEGER NOT NULL DEFAULT 0,
|
|
80
|
+
stalledReason TEXT NOT NULL DEFAULT '',
|
|
81
|
+
stalledAt INTEGER NOT NULL DEFAULT 0,
|
|
82
|
+
blockedBy TEXT NOT NULL DEFAULT '[]',
|
|
83
|
+
inceptionSnapshotId TEXT NOT NULL DEFAULT '',
|
|
79
84
|
updatedAt INTEGER NOT NULL
|
|
80
85
|
);
|
|
81
86
|
CREATE INDEX IF NOT EXISTS tasks_board_col ON tasks(board, col, position);
|
|
@@ -110,14 +115,16 @@ const WRITABLE = [
|
|
|
110
115
|
'workflowRunId', 'workflowStatus', 'permissionConfirmedTier', 'forge', 'queuedRequest',
|
|
111
116
|
'verificationCommand', 'verificationStatus',
|
|
112
117
|
'costMetrics', 'costLimitExceeded',
|
|
118
|
+
'stalled', 'stalledReason', 'stalledAt',
|
|
119
|
+
'blockedBy', 'inceptionSnapshotId',
|
|
113
120
|
]
|
|
114
121
|
|
|
115
122
|
/** Valid transition sources; unexpected values are rejected to preserve log integrity. */
|
|
116
|
-
const SOURCES = new Set(['manual', 'session', 'gitea', 'github', 'tool', 'workflow'])
|
|
123
|
+
const SOURCES = new Set(['manual', 'session', 'gitea', 'github', 'tool', 'workflow', 'watchdog'])
|
|
117
124
|
|
|
118
125
|
function rowToTask(row) {
|
|
119
126
|
if (row === undefined) return undefined
|
|
120
|
-
const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, isEpic, parentId, ...rest } = row
|
|
127
|
+
const { col, labels, waiting, labelColors, runs, checklist, report, comments, rejectReason, rejectedAt, isEpic, parentId, stalled, blockedBy, inceptionSnapshotId, ...rest } = row
|
|
121
128
|
let parsedChecklist = []
|
|
122
129
|
try {
|
|
123
130
|
const value = JSON.parse(checklist ?? '[]')
|
|
@@ -173,6 +180,13 @@ function rowToTask(row) {
|
|
|
173
180
|
return rest.costMetrics && typeof rest.costMetrics === 'object' ? rest.costMetrics : null;
|
|
174
181
|
})(),
|
|
175
182
|
costLimitExceeded: rest.costLimitExceeded === 1 || rest.costLimitExceeded === true,
|
|
183
|
+
blockedBy: (() => {
|
|
184
|
+
try {
|
|
185
|
+
const val = JSON.parse(blockedBy ?? '[]')
|
|
186
|
+
return Array.isArray(val) ? val.filter((x) => typeof x === 'string') : []
|
|
187
|
+
} catch { return [] }
|
|
188
|
+
})(),
|
|
189
|
+
inceptionSnapshotId: typeof inceptionSnapshotId === 'string' ? inceptionSnapshotId : (typeof rest.inceptionSnapshotId === 'string' ? rest.inceptionSnapshotId : ''),
|
|
176
190
|
}
|
|
177
191
|
}
|
|
178
192
|
|
|
@@ -265,6 +279,11 @@ export function openStore({ dir }) {
|
|
|
265
279
|
['verificationStatus', "TEXT NOT NULL DEFAULT ''"],
|
|
266
280
|
['costMetrics', "TEXT NOT NULL DEFAULT ''"],
|
|
267
281
|
['costLimitExceeded', "INTEGER NOT NULL DEFAULT 0"],
|
|
282
|
+
['stalled', 'INTEGER NOT NULL DEFAULT 0'],
|
|
283
|
+
['stalledReason', "TEXT NOT NULL DEFAULT ''"],
|
|
284
|
+
['stalledAt', 'INTEGER NOT NULL DEFAULT 0'],
|
|
285
|
+
['blockedBy', "TEXT NOT NULL DEFAULT '[]'"],
|
|
286
|
+
['inceptionSnapshotId', "TEXT NOT NULL DEFAULT ''"],
|
|
268
287
|
]) {
|
|
269
288
|
try { db.exec(`ALTER TABLE tasks ADD COLUMN ${column} ${definition}`) } catch { /* column already exists */ }
|
|
270
289
|
}
|
|
@@ -607,6 +626,8 @@ export function openStore({ dir }) {
|
|
|
607
626
|
else if (key === 'syncedAt') values.push(Number(patch[key]) || 0)
|
|
608
627
|
else if (key === 'isEpic') values.push(patch[key] ? 1 : 0)
|
|
609
628
|
else if (key === 'parentId') values.push(String(patch[key] ?? ''))
|
|
629
|
+
else if (key === 'blockedBy') values.push(Array.isArray(patch[key]) ? JSON.stringify(patch[key]) : (typeof patch[key] === 'string' ? patch[key] : '[]'))
|
|
630
|
+
else if (key === 'inceptionSnapshotId') values.push(String(patch[key] ?? ''))
|
|
610
631
|
else values.push(patch[key] ?? null)
|
|
611
632
|
}
|
|
612
633
|
if (fields.length > 0) {
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// Integration service for @goodandready/dsh-time-machine snapshot and rollback capabilities.
|
|
2
|
+
// Provides automatic inception snapshots on task start and one-click rollback to clean state.
|
|
3
|
+
// Degrades gracefully when @goodandready/dsh-time-machine plugin is not installed in DSH profile.
|
|
4
|
+
|
|
5
|
+
import { resolveTaskWorktreePath } from './worktree.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check whether timeMachineEngine is available in the Cordis context.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} ctx Cordis context or harness
|
|
11
|
+
* @returns {boolean} true if time machine engine is available
|
|
12
|
+
*/
|
|
13
|
+
export function isTimeMachineAvailable(ctx) {
|
|
14
|
+
if (!ctx || typeof ctx !== 'object') return false
|
|
15
|
+
const engine = ctx.timeMachineEngine || (typeof ctx.get === 'function' ? (ctx.get('timeMachineEngine') || ctx.get('timeMachine')) : null)
|
|
16
|
+
return !!(engine && (typeof engine.createSnapshot === 'function' || typeof engine.rollbackSnapshot === 'function' || typeof engine.listSnapshots === 'function'))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the timeMachineEngine instance from context.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} ctx Cordis context
|
|
23
|
+
* @returns {object|null}
|
|
24
|
+
*/
|
|
25
|
+
export function getTimeMachineEngine(ctx) {
|
|
26
|
+
if (!ctx || typeof ctx !== 'object') return null
|
|
27
|
+
const engine = ctx.timeMachineEngine || (typeof ctx.get === 'function' ? (ctx.get('timeMachineEngine') || ctx.get('timeMachine')) : null)
|
|
28
|
+
if (engine && (typeof engine.createSnapshot === 'function' || typeof engine.rollbackSnapshot === 'function' || typeof engine.listSnapshots === 'function')) {
|
|
29
|
+
return engine
|
|
30
|
+
}
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the directory path to snapshot for a task.
|
|
36
|
+
*
|
|
37
|
+
* @param {{task: object, config?: object, env?: object, cwdOf?: Function}} options
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
export function resolveSnapshotDir({ task, config, env, cwdOf }) {
|
|
41
|
+
if (task?.worktree && typeof task.worktree === 'string' && task.worktree.trim() !== '') {
|
|
42
|
+
return task.worktree.trim()
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const resolved = resolveTaskWorktreePath({ task, config, env, cwdOf: cwdOf || (() => process.cwd()) })
|
|
46
|
+
if (resolved) return resolved
|
|
47
|
+
} catch {
|
|
48
|
+
/* fallback to cwd */
|
|
49
|
+
}
|
|
50
|
+
return typeof cwdOf === 'function' ? cwdOf() : process.cwd()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Automatically create inception snapshot when task moves to in-progress.
|
|
55
|
+
*
|
|
56
|
+
* @param {{ctx: object, task: object, config?: object, env?: object, cwdOf?: Function, store?: object, logger?: object}} options
|
|
57
|
+
* @returns {Promise<{available: boolean, created: boolean, snapshotId?: string, alreadyExists?: boolean, snapshot?: object, error?: string}>}
|
|
58
|
+
*/
|
|
59
|
+
export async function createInceptionSnapshot({ ctx, task, config, env, cwdOf, store, logger }) {
|
|
60
|
+
const engine = getTimeMachineEngine(ctx)
|
|
61
|
+
if (!engine) {
|
|
62
|
+
return { available: false, created: false }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (task?.inceptionSnapshotId && typeof task.inceptionSnapshotId === 'string' && task.inceptionSnapshotId.trim() !== '') {
|
|
66
|
+
return {
|
|
67
|
+
available: true,
|
|
68
|
+
created: false,
|
|
69
|
+
alreadyExists: true,
|
|
70
|
+
snapshotId: task.inceptionSnapshotId.trim(),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const targetDir = resolveSnapshotDir({ task, config, env, cwdOf })
|
|
75
|
+
const label = `Inception: ${task?.id || 'task'} (${task?.title || 'untitled'})`
|
|
76
|
+
const sessionId = `task-${task?.id || 'default'}`
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const snapshot = await engine.createSnapshot(label, {
|
|
80
|
+
sessionId,
|
|
81
|
+
cwd: targetDir,
|
|
82
|
+
skipIfNoChanges: false,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
if (snapshot?.id && store && typeof store.updateTask === 'function' && task?.id) {
|
|
86
|
+
store.updateTask(task.id, { inceptionSnapshotId: snapshot.id })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
available: true,
|
|
91
|
+
created: true,
|
|
92
|
+
snapshotId: snapshot?.id,
|
|
93
|
+
snapshot,
|
|
94
|
+
}
|
|
95
|
+
} catch (err) {
|
|
96
|
+
logger?.warn?.('[dsh-kanban] Failed to create inception snapshot:', err?.message || err)
|
|
97
|
+
return {
|
|
98
|
+
available: true,
|
|
99
|
+
created: false,
|
|
100
|
+
error: err?.message || String(err),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Manually create a checkpoint snapshot for a task.
|
|
107
|
+
*
|
|
108
|
+
* @param {{ctx: object, task: object, label?: string, config?: object, env?: object, cwdOf?: Function, logger?: object}} options
|
|
109
|
+
* @returns {Promise<{available: boolean, created: boolean, snapshot?: object, error?: string}>}
|
|
110
|
+
*/
|
|
111
|
+
export async function createCheckpointSnapshot({ ctx, task, label, config, env, cwdOf, logger }) {
|
|
112
|
+
const engine = getTimeMachineEngine(ctx)
|
|
113
|
+
if (!engine) {
|
|
114
|
+
return { available: false, created: false }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const targetDir = resolveSnapshotDir({ task, config, env, cwdOf })
|
|
118
|
+
const snapshotLabel = `Checkpoint: ${label || 'manual'} (${task?.id || 'task'})`
|
|
119
|
+
const sessionId = `task-${task?.id || 'default'}`
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const snapshot = await engine.createSnapshot(snapshotLabel, {
|
|
123
|
+
sessionId,
|
|
124
|
+
cwd: targetDir,
|
|
125
|
+
skipIfNoChanges: false,
|
|
126
|
+
})
|
|
127
|
+
return {
|
|
128
|
+
available: true,
|
|
129
|
+
created: true,
|
|
130
|
+
snapshot,
|
|
131
|
+
}
|
|
132
|
+
} catch (err) {
|
|
133
|
+
logger?.warn?.('[dsh-kanban] Failed to create checkpoint snapshot:', err?.message || err)
|
|
134
|
+
return {
|
|
135
|
+
available: true,
|
|
136
|
+
created: false,
|
|
137
|
+
error: err?.message || String(err),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Roll back task files to a snapshot (default: inception snapshot).
|
|
144
|
+
*
|
|
145
|
+
* @param {{ctx: object, task: object, snapshotId?: string, config?: object, env?: object, cwdOf?: Function, logger?: object}} options
|
|
146
|
+
* @returns {Promise<{available: boolean, rolledBack: boolean, snapshotId?: string, result?: object, error?: string, message?: string}>}
|
|
147
|
+
*/
|
|
148
|
+
export async function rollbackToSnapshot({ ctx, task, snapshotId, config, env, cwdOf, logger }) {
|
|
149
|
+
const engine = getTimeMachineEngine(ctx)
|
|
150
|
+
if (!engine) {
|
|
151
|
+
return {
|
|
152
|
+
available: false,
|
|
153
|
+
rolledBack: false,
|
|
154
|
+
error: 'time-machine-unavailable',
|
|
155
|
+
message: 'Plugin @goodandready/dsh-time-machine is not installed or active in this profile.',
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const targetId = snapshotId || task?.inceptionSnapshotId
|
|
160
|
+
if (!targetId || typeof targetId !== 'string' || targetId.trim() === '') {
|
|
161
|
+
return {
|
|
162
|
+
available: true,
|
|
163
|
+
rolledBack: false,
|
|
164
|
+
error: 'no-snapshot-id',
|
|
165
|
+
message: 'No snapshot ID specified and task has no inception snapshot.',
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const targetDir = resolveSnapshotDir({ task, config, env, cwdOf })
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const result = await engine.rollbackSnapshot(String(targetId).trim(), {
|
|
173
|
+
confirm: true,
|
|
174
|
+
cwd: targetDir,
|
|
175
|
+
})
|
|
176
|
+
return {
|
|
177
|
+
available: true,
|
|
178
|
+
rolledBack: true,
|
|
179
|
+
snapshotId: targetId,
|
|
180
|
+
result,
|
|
181
|
+
}
|
|
182
|
+
} catch (err) {
|
|
183
|
+
logger?.error?.('[dsh-kanban] Failed to rollback snapshot:', err?.message || err)
|
|
184
|
+
return {
|
|
185
|
+
available: true,
|
|
186
|
+
rolledBack: false,
|
|
187
|
+
error: 'rollback-failed',
|
|
188
|
+
message: err?.message || String(err),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* List snapshots for a task.
|
|
195
|
+
*
|
|
196
|
+
* @param {{ctx: object, task: object}} options
|
|
197
|
+
* @returns {Promise<Array<object>>}
|
|
198
|
+
*/
|
|
199
|
+
export async function listTaskSnapshots({ ctx, task }) {
|
|
200
|
+
const engine = getTimeMachineEngine(ctx)
|
|
201
|
+
if (!engine || !task?.id) return []
|
|
202
|
+
|
|
203
|
+
const sessionId = `task-${task.id}`
|
|
204
|
+
try {
|
|
205
|
+
if (typeof engine.listSnapshots === 'function') {
|
|
206
|
+
const list = engine.listSnapshots(sessionId)
|
|
207
|
+
return Array.isArray(list) ? list : []
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
/* ignore error */
|
|
211
|
+
}
|
|
212
|
+
return []
|
|
213
|
+
}
|
|
214
|
+
|
package/lib/watchdog.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watchdog timer and Circuit Breaker service for agent tasks.
|
|
3
|
+
*
|
|
4
|
+
* Automatically monitors tasks in 'in-progress'.
|
|
5
|
+
* If a task is inactive longer than watchdogStallTimeoutMin or incurs
|
|
6
|
+
* watchdogMaxConsecutiveErrors consecutive execution errors,
|
|
7
|
+
* the circuit breaker trips:
|
|
8
|
+
* 1. The agent session is aborted/paused to avoid burning quota.
|
|
9
|
+
* 2. The task is marked as stalled with diagnostic detail.
|
|
10
|
+
* 3. A diagnostic transition is added to the task journal.
|
|
11
|
+
* 4. A 'task:change' event is broadcast via eventHub.
|
|
12
|
+
*
|
|
13
|
+
* Tasks can be resumed manually via resumeTask or /task/:id/resume.
|
|
14
|
+
*/
|
|
15
|
+
export class WatchdogService {
|
|
16
|
+
constructor() {
|
|
17
|
+
this.consecutiveErrors = new Map()
|
|
18
|
+
this.lastActive = new Map()
|
|
19
|
+
this.lastError = new Map()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
recordActivity(taskId, _type = 'activity') {
|
|
23
|
+
if (!taskId) return
|
|
24
|
+
this.lastActive.set(taskId, Date.now())
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
recordError(taskId, error) {
|
|
28
|
+
if (!taskId) return
|
|
29
|
+
const count = (this.consecutiveErrors.get(taskId) || 0) + 1
|
|
30
|
+
this.consecutiveErrors.set(taskId, count)
|
|
31
|
+
const msg = typeof error === 'string' ? error : (error?.message || error?.name || 'Execution error')
|
|
32
|
+
this.lastError.set(taskId, msg)
|
|
33
|
+
this.lastActive.set(taskId, Date.now())
|
|
34
|
+
return count
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
clearError(taskId) {
|
|
38
|
+
if (!taskId) return
|
|
39
|
+
this.consecutiveErrors.delete(taskId)
|
|
40
|
+
this.lastError.delete(taskId)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
clearTask(taskId) {
|
|
44
|
+
if (!taskId) return
|
|
45
|
+
this.consecutiveErrors.delete(taskId)
|
|
46
|
+
this.lastError.delete(taskId)
|
|
47
|
+
this.lastActive.delete(taskId)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
checkTask(task, options = {}) {
|
|
51
|
+
const { config = {}, now = Date.now() } = options
|
|
52
|
+
if (config.watchdogEnabled === false) return { shouldTrip: false }
|
|
53
|
+
if (!task || task.column !== 'in-progress' || task.stalled) return { shouldTrip: false }
|
|
54
|
+
|
|
55
|
+
const timeoutMs = (Number(config.watchdogStallTimeoutMin) || 15) * 60 * 1000
|
|
56
|
+
const maxErrors = Number(config.watchdogMaxConsecutiveErrors) || 5
|
|
57
|
+
|
|
58
|
+
// Check consecutive errors
|
|
59
|
+
const errCount = this.consecutiveErrors.get(task.id) || 0
|
|
60
|
+
if (errCount >= maxErrors) {
|
|
61
|
+
const lastErr = this.lastError.get(task.id) || 'ToolExecutionError'
|
|
62
|
+
return {
|
|
63
|
+
shouldTrip: true,
|
|
64
|
+
reason: `Circuit breaker tripped: ${errCount} consecutive execution errors (${lastErr})`,
|
|
65
|
+
errCount,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Check inactivity timeout
|
|
70
|
+
const last = this.lastActive.get(task.id) || task.updatedAt || task.columnAt || task.createdAt || now
|
|
71
|
+
const idleMs = now - last
|
|
72
|
+
if (idleMs >= timeoutMs) {
|
|
73
|
+
const idleMin = Math.round(idleMs / 60000)
|
|
74
|
+
return {
|
|
75
|
+
shouldTrip: true,
|
|
76
|
+
reason: `Watchdog timeout: task inactive for ${idleMin} minutes (threshold ${config.watchdogStallTimeoutMin || 15}m)`,
|
|
77
|
+
idleMs,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { shouldTrip: false }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
tripTask(task, reason, options = {}) {
|
|
85
|
+
const { store, agents, eventHub, logger } = options
|
|
86
|
+
if (!task) return
|
|
87
|
+
|
|
88
|
+
// 1. Abort/pause session if running
|
|
89
|
+
if (task.sessionId && agents) {
|
|
90
|
+
try {
|
|
91
|
+
const agent = agents.get?.(task.sessionId)
|
|
92
|
+
if (agent && typeof agent.abort === 'function') {
|
|
93
|
+
agent.abort('watchdog: circuit breaker tripped')
|
|
94
|
+
} else if (agent && typeof agent.pause === 'function') {
|
|
95
|
+
agent.pause()
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
logger?.warn?.(`dsh-kanban: watchdog failed to abort agent for session ${task.sessionId}: ${err?.message}`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 2. Update task in store
|
|
103
|
+
if (store && typeof store.updateTask === 'function') {
|
|
104
|
+
store.updateTask(task.id, {
|
|
105
|
+
stalled: 1,
|
|
106
|
+
stalledReason: reason,
|
|
107
|
+
stalledAt: Date.now(),
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// 3. Journal transition
|
|
111
|
+
if (typeof store.addTransition === 'function') {
|
|
112
|
+
store.addTransition({
|
|
113
|
+
taskId: task.id,
|
|
114
|
+
fromCol: task.column,
|
|
115
|
+
toCol: task.column,
|
|
116
|
+
source: 'watchdog',
|
|
117
|
+
detail: reason,
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 4. Broadcast event
|
|
123
|
+
eventHub?.broadcast?.('task:change', {
|
|
124
|
+
taskId: task.id,
|
|
125
|
+
action: 'stalled',
|
|
126
|
+
stalled: true,
|
|
127
|
+
reason,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
logger?.warn?.(`dsh-kanban: watchdog tripped for task ${task.id}: ${reason}`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
checkTasks(options = {}) {
|
|
134
|
+
const { store, agents, config = {}, eventHub, logger } = options
|
|
135
|
+
if (config.watchdogEnabled === false || !store) return []
|
|
136
|
+
|
|
137
|
+
const tripped = []
|
|
138
|
+
const inProgressTasks = store.listTasks({ column: 'in-progress' })
|
|
139
|
+
const now = Date.now()
|
|
140
|
+
|
|
141
|
+
for (const task of inProgressTasks) {
|
|
142
|
+
const check = this.checkTask(task, { config, now })
|
|
143
|
+
if (check.shouldTrip) {
|
|
144
|
+
this.tripTask(task, check.reason, { store, agents, eventHub, logger })
|
|
145
|
+
tripped.push({ taskId: task.id, reason: check.reason })
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return tripped
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
resumeTask(options = {}) {
|
|
152
|
+
const { store, taskId, eventHub } = options
|
|
153
|
+
if (!taskId) return
|
|
154
|
+
if (store && typeof store.updateTask === 'function') {
|
|
155
|
+
store.updateTask(taskId, {
|
|
156
|
+
stalled: 0,
|
|
157
|
+
stalledReason: '',
|
|
158
|
+
stalledAt: 0,
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
this.clearTask(taskId)
|
|
162
|
+
this.recordActivity(taskId, 'resume')
|
|
163
|
+
eventHub?.broadcast?.('task:change', { taskId, action: 'resumed', stalled: false })
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function createWatchdogService() {
|
|
168
|
+
return new WatchdogService()
|
|
169
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-kanban",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.36",
|
|
4
4
|
"description": "Visual Kanban board for DeepSeek Harness: Gitea and GitHub-backed tasks, workflow columns, and a dedicated agent session per task.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|