@goodandready/dsh-agent-orchestrator 0.1.9 → 0.1.10

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/client.js CHANGED
@@ -897,7 +897,14 @@ window.__ModuleLoader__.load({
897
897
  if (res.ok) {
898
898
  const data = await res.json()
899
899
  const active = (data.activePipelines || [])[0] || null
900
- setActivePipeline(active)
900
+ setActivePipeline((prev) => {
901
+ if (!prev && !active) return null
902
+ if (!prev || !active) return active
903
+ if (prev.pipelineId === active.pipelineId && prev.status === active.status && prev.durationMs === active.durationMs) {
904
+ return prev
905
+ }
906
+ return active
907
+ })
901
908
 
902
909
  // If no active, check recent completed/failed within last 60 seconds
903
910
  if (!active && data.recentPipelines && data.recentPipelines.length > 0) {
@@ -917,12 +924,14 @@ window.__ModuleLoader__.load({
917
924
  }
918
925
  }, [dismissedId])
919
926
 
927
+ const hasActive = Boolean(activePipeline)
928
+
920
929
  useEffect(() => {
921
930
  pollStatus()
922
- const intervalMs = activePipeline ? 2000 : 5000
931
+ const intervalMs = hasActive ? 2000 : 5000
923
932
  const timer = setInterval(pollStatus, intervalMs)
924
933
  return () => clearInterval(timer)
925
- }, [activePipeline, pollStatus])
934
+ }, [hasActive, pollStatus])
926
935
 
927
936
  const handleCancel = async (pipelineId) => {
928
937
  try {
package/lib/index.js CHANGED
@@ -292,10 +292,22 @@ export function apply(ctx, config = {}) {
292
292
  }
293
293
  }
294
294
 
295
+ const activeAbortControllers = new Map()
296
+
295
297
  /**
296
298
  * Runner object to start, execute, and monitor pipelines
297
299
  */
298
300
  const runner = {
301
+ cancelPipeline(pipelineId) {
302
+ const controller = activeAbortControllers.get(pipelineId)
303
+ if (controller) {
304
+ controller.abort()
305
+ activeAbortControllers.delete(pipelineId)
306
+ return true
307
+ }
308
+ return false
309
+ },
310
+
299
311
  async startPipeline({ taskTitle, taskDescription, scenarioId, taskId }) {
300
312
  const cfg = getConfig()
301
313
  const plan = decomposeTask({
@@ -369,17 +381,24 @@ export function apply(ctx, config = {}) {
369
381
  }
370
382
  }
371
383
 
384
+ const abortController = new AbortController()
385
+ activeAbortControllers.set(plan.pipelineId, abortController)
386
+
372
387
  executeDAG({
373
388
  stages: plan.stages,
374
389
  executor,
375
390
  concurrency: 3,
391
+ signal: abortController.signal,
376
392
  })
377
393
  .then(async (dagResult) => {
378
- store.updatePipeline(plan.pipelineId, {
379
- status: 'completed',
380
- completedAt: Date.now(),
394
+ activeAbortControllers.delete(plan.pipelineId)
395
+ const currentP = store.getPipeline(plan.pipelineId)
396
+ if (currentP?.status === 'failed' && currentP?.error === 'Cancelled by user') {
397
+ return
398
+ }
399
+ store.recordCompletion(plan.pipelineId, {
400
+ ...dagResult,
381
401
  durationMs: Date.now() - pipelineData.startedAt,
382
- artifacts: dagResult.artifacts,
383
402
  })
384
403
 
385
404
  // Sync to Kanban if enabled
@@ -399,7 +418,7 @@ export function apply(ctx, config = {}) {
399
418
  if (syncConfig.createChecklist) {
400
419
  const checklistItems = plan.stages.map((s) => ({
401
420
  title: `${s.name} (${s.roleName})`,
402
- completed: dagResult.statusMap[s.id] === 'completed',
421
+ completed: dagResult?.stateMap?.[s.id]?.status === 'completed',
403
422
  }))
404
423
  await kanbanBridge.syncStageChecklist(targetTaskId, checklistItems)
405
424
  }
@@ -419,7 +438,7 @@ export function apply(ctx, config = {}) {
419
438
  id: plan.pipelineId,
420
439
  title: plan.taskTitle,
421
440
  scenarioId: plan.scenarioId,
422
- status: dagResult.allSucceeded ? 'completed' : 'failed',
441
+ status: dagResult?.success ? 'completed' : 'failed',
423
442
  stages: plan.stages,
424
443
  durationMs: Date.now() - pipelineData.startedAt,
425
444
  })
@@ -428,9 +447,13 @@ export function apply(ctx, config = {}) {
428
447
  }
429
448
  })
430
449
  .catch((dagErr) => {
431
- store.updatePipeline(plan.pipelineId, {
432
- status: 'failed',
433
- completedAt: Date.now(),
450
+ activeAbortControllers.delete(plan.pipelineId)
451
+ const currentP = store.getPipeline(plan.pipelineId)
452
+ if (currentP?.status === 'failed' && currentP?.error === 'Cancelled by user') {
453
+ return
454
+ }
455
+ store.recordCompletion(plan.pipelineId, {
456
+ success: false,
434
457
  durationMs: Date.now() - pipelineData.startedAt,
435
458
  error: dagErr?.message || String(dagErr),
436
459
  })
@@ -813,8 +836,11 @@ export function apply(ctx, config = {}) {
813
836
  }
814
837
  }
815
838
 
839
+ let hasCommandsService = false
840
+
816
841
  // Chat slash commands: /orchestrate and /orc via commands service
817
842
  ctx.inject(['commands'], (cctx) => {
843
+ hasCommandsService = true
818
844
  try {
819
845
  if (typeof cctx.commands?.register !== 'function') return
820
846
 
@@ -904,6 +930,7 @@ export function apply(ctx, config = {}) {
904
930
 
905
931
  if (typeof cctx.effect === 'function') {
906
932
  cctx.effect(() => () => {
933
+ hasCommandsService = false
907
934
  try {
908
935
  if (typeof unregister1 === 'function') unregister1()
909
936
  if (typeof unregister2 === 'function') unregister2()
@@ -938,6 +965,11 @@ export function apply(ctx, config = {}) {
938
965
  const text = String(event.text || event.content || '').trim()
939
966
  const intent = detectOrchestratorIntent(text)
940
967
 
968
+ // Prevent duplicate dispatch: if commands service is active, slash commands are handled by cctx.commands (#148)
969
+ if (hasCommandsService && intent.triggerKind === 'slash') {
970
+ return
971
+ }
972
+
941
973
  if (intent.isTrigger && intent.action === 'on') {
942
974
  await handlePipelineDispatch({
943
975
  taskTitle: intent.taskTitle || 'Interactive Orchestrated Task',
@@ -149,12 +149,14 @@ export async function executeDAG(stagesOrOptions, executorArg, optionsArg = {})
149
149
  let onNodeComplete = () => {}
150
150
  let onNodeFail = () => {}
151
151
  let onNodeBlocked = () => {}
152
+ let signal = null
152
153
 
153
154
  if (Array.isArray(stagesOrOptions)) {
154
155
  stages = stagesOrOptions
155
156
  executor = executorArg
156
157
  concurrency = optionsArg.concurrency || optionsArg.maxConcurrency || 4
157
158
  initialContext = optionsArg.initialContext || {}
159
+ signal = optionsArg.signal || null
158
160
  if (typeof optionsArg.onNodeStart === 'function') onNodeStart = optionsArg.onNodeStart
159
161
  if (typeof optionsArg.onNodeComplete === 'function') onNodeComplete = optionsArg.onNodeComplete
160
162
  if (typeof optionsArg.onNodeFail === 'function') onNodeFail = optionsArg.onNodeFail
@@ -164,6 +166,7 @@ export async function executeDAG(stagesOrOptions, executorArg, optionsArg = {})
164
166
  executor = stagesOrOptions.executor
165
167
  concurrency = stagesOrOptions.concurrency || stagesOrOptions.maxConcurrency || 4
166
168
  initialContext = stagesOrOptions.initialContext || {}
169
+ signal = stagesOrOptions.signal || null
167
170
  if (typeof stagesOrOptions.onNodeStart === 'function') onNodeStart = stagesOrOptions.onNodeStart
168
171
  if (typeof stagesOrOptions.onNodeComplete === 'function') onNodeComplete = stagesOrOptions.onNodeComplete
169
172
  if (typeof stagesOrOptions.onNodeFail === 'function') onNodeFail = stagesOrOptions.onNodeFail
@@ -220,13 +223,33 @@ export async function executeDAG(stagesOrOptions, executorArg, optionsArg = {})
220
223
  function triggerNext() {
221
224
  if (isDone) return
222
225
 
226
+ if (signal?.aborted) {
227
+ if (!isDone && runningCount === 0) {
228
+ isDone = true
229
+ for (const s of stages) {
230
+ const st = stateMap.get(s.id)
231
+ if (st.status === NODE_STATUS.PENDING) {
232
+ st.status = NODE_STATUS.SKIPPED
233
+ }
234
+ }
235
+ resolvePromise({
236
+ success: false,
237
+ cancelled: true,
238
+ durationMs: Date.now() - startTime,
239
+ stateMap: Object.fromEntries(stateMap),
240
+ artifacts: Object.fromEntries(artifacts),
241
+ })
242
+ }
243
+ return
244
+ }
245
+
223
246
  // Propagate blockers first
224
247
  if (markBlockedStages(stages, stateMap)) {
225
248
  for (const s of stages) {
226
249
  const st = stateMap.get(s.id)
227
250
  if (st.status === NODE_STATUS.BLOCKED && !st.notifiedBlocked) {
228
251
  st.notifiedBlocked = true
229
- onNodeBlocked(st)
252
+ try { onNodeBlocked(st) } catch (_) { /* Safe ignore notification error */ }
230
253
  }
231
254
  }
232
255
  }
@@ -241,7 +264,7 @@ export async function executeDAG(stagesOrOptions, executorArg, optionsArg = {})
241
264
  nodeState.startedAt = Date.now()
242
265
  runningCount++
243
266
 
244
- onNodeStart(nodeState)
267
+ try { onNodeStart(nodeState) } catch (_) { /* Safe ignore notification error */ }
245
268
 
246
269
  // Run task asynchronously
247
270
  const upstreamOutputs = {}
@@ -266,17 +289,28 @@ export async function executeDAG(stagesOrOptions, executorArg, optionsArg = {})
266
289
 
267
290
  artifacts.set(nextStage.id, nodeState.output)
268
291
  runningCount--
269
- onNodeComplete(nodeState)
292
+ try {
293
+ onNodeComplete(nodeState)
294
+ } catch (_) {
295
+ /* Callback error must not alter completed state or double-decrement runningCount */
296
+ }
270
297
  triggerNext()
271
298
  })
272
299
  .catch((err) => {
300
+ if (nodeState.status === NODE_STATUS.COMPLETED) {
301
+ return
302
+ }
273
303
  nodeState.status = NODE_STATUS.FAILED
274
304
  nodeState.completedAt = Date.now()
275
305
  nodeState.durationMs = nodeState.completedAt - nodeState.startedAt
276
306
  nodeState.error = err?.message || String(err)
277
307
 
278
308
  runningCount--
279
- onNodeFail(nodeState, err)
309
+ try {
310
+ onNodeFail(nodeState, err)
311
+ } catch (_) {
312
+ /* Callback error must not crash the engine */
313
+ }
280
314
  triggerNext()
281
315
  })
282
316
  }
@@ -284,6 +318,12 @@ export async function executeDAG(stagesOrOptions, executorArg, optionsArg = {})
284
318
  checkCompletion()
285
319
  }
286
320
 
321
+ if (signal) {
322
+ signal.addEventListener('abort', () => {
323
+ triggerNext()
324
+ }, { once: true })
325
+ }
326
+
287
327
  // Initial trigger
288
328
  triggerNext()
289
329
  })
@@ -152,8 +152,18 @@ export function normalizeRoleId(input, availableRoles = []) {
152
152
  if (role.displayName && role.displayName.toLowerCase() === lower) return role.id
153
153
  }
154
154
 
155
+ function matchesRoleAlias(str, alias) {
156
+ if (str === alias) return true
157
+ if (alias.length <= 3) {
158
+ const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
159
+ const regex = new RegExp('(^|[^a-z0-9а-яё])' + escaped + '([^a-z0-9а-яё]|$)', 'i')
160
+ return regex.test(str)
161
+ }
162
+ return str.includes(alias)
163
+ }
164
+
155
165
  for (const [alias, id] of Object.entries(ROLE_INPUT_ALIASES)) {
156
- if (lower === alias || lower.includes(alias)) {
166
+ if (matchesRoleAlias(lower, alias)) {
157
167
  const found = rolesList.find((r) => r.id === id)
158
168
  if (found) return found.id
159
169
  if (id === 'code') {
@@ -51,12 +51,13 @@ export function detectOrchestratorIntent(text) {
51
51
  if (slashMatch) {
52
52
  const rest = (slashMatch[1] || '').trim()
53
53
  if (rest.toLowerCase() === 'off') {
54
- return { isTrigger: true, action: 'off' }
54
+ return { isTrigger: true, action: 'off', triggerKind: 'slash' }
55
55
  }
56
56
  const { scenarioId, taskTitle } = extractScenarioAndTask(rest)
57
57
  return {
58
58
  isTrigger: true,
59
59
  action: 'on',
60
+ triggerKind: 'slash',
60
61
  scenarioId,
61
62
  taskTitle,
62
63
  }
@@ -73,6 +74,7 @@ export function detectOrchestratorIntent(text) {
73
74
  return {
74
75
  isTrigger: true,
75
76
  action: 'on',
77
+ triggerKind: 'nl',
76
78
  scenarioId,
77
79
  taskTitle,
78
80
  }
@@ -89,6 +91,7 @@ export function detectOrchestratorIntent(text) {
89
91
  return {
90
92
  isTrigger: true,
91
93
  action: 'on',
94
+ triggerKind: 'nl',
92
95
  scenarioId,
93
96
  taskTitle,
94
97
  }
@@ -569,7 +569,6 @@ export async function fetchModelCatalog(ctx, options = {}) {
569
569
  const defaultPool = [
570
570
  { provider: 'deepseek-official', model: 'deepseek-chat', maxTokens: 4096 },
571
571
  { provider: 'deepseek-official', model: 'deepseek-reasoner', maxTokens: 8192 },
572
- { provider: 'anthropic', model: 'claude-3-5-sonnet', maxTokens: 8192 },
573
572
  ]
574
573
  for (const item of defaultPool) {
575
574
  const capabilities = inferModelCapabilities(item.model, item.provider)
@@ -23,6 +23,7 @@ export class SnapshotManager {
23
23
  this.baseDir = baseDir || path.join(os.homedir(), '.dsh', 'orchestrator-snapshots')
24
24
  this.maxSnapshots = maxSnapshots
25
25
  this.logger = logger || null
26
+ this._snapshotsCache = null
26
27
  this._ensureDir()
27
28
  }
28
29
 
@@ -65,6 +66,9 @@ export class SnapshotManager {
65
66
  */
66
67
  listSnapshots() {
67
68
  this._ensureDir()
69
+ if (this._snapshotsCache) {
70
+ return [...this._snapshotsCache]
71
+ }
68
72
  try {
69
73
  const files = fs.readdirSync(this.baseDir)
70
74
  const snapshots = []
@@ -91,7 +95,8 @@ export class SnapshotManager {
91
95
  return timeB - timeA
92
96
  })
93
97
 
94
- return snapshots
98
+ this._snapshotsCache = snapshots
99
+ return [...snapshots]
95
100
  } catch (err) {
96
101
  if (this.logger?.error) {
97
102
  this.logger.error('[SnapshotManager] Error listing snapshots:', err?.message || err)
@@ -158,7 +163,7 @@ export class SnapshotManager {
158
163
  const tmpPath = path.join(this.baseDir, `${snapshotId}.tmp-${Date.now()}`)
159
164
 
160
165
  try {
161
- fs.writeFileSync(tmpPath, JSON.stringify(record, null, 2), 'utf8')
166
+ fs.writeFileSync(tmpPath, JSON.stringify(record), 'utf8')
162
167
  fs.renameSync(tmpPath, filePath)
163
168
  } catch (err) {
164
169
  try {
@@ -169,6 +174,7 @@ export class SnapshotManager {
169
174
  throw err
170
175
  }
171
176
 
177
+ this._snapshotsCache = null
172
178
  // Enforce FIFO retention policy: if file count > maxSnapshots, prune oldest
173
179
  this._enforceRetention()
174
180
 
@@ -179,6 +185,7 @@ export class SnapshotManager {
179
185
  * Prunes oldest snapshot files when total exceeds maxSnapshots ceiling.
180
186
  */
181
187
  _enforceRetention() {
188
+ this._snapshotsCache = null
182
189
  try {
183
190
  const files = fs.readdirSync(this.baseDir).filter((f) => f.endsWith('.json'))
184
191
  if (files.length <= this.maxSnapshots) return
@@ -84,6 +84,7 @@ export async function executeStageWorker(stage, context) {
84
84
  output: artContent || '',
85
85
  })
86
86
  }
87
+ priorArtifactsList.sort((a, b) => a.stageId.localeCompare(b.stageId))
87
88
  const cumulativeArtifacts = formatCumulativeArtifacts(priorArtifactsList)
88
89
 
89
90
  // 4. Assemble canonical prompt (Layer 1 + 2 + 3 in System, Layer 4 in User)
package/lib/routes.js CHANGED
@@ -29,6 +29,9 @@ export function registerOrchestratorRoutes(ctx, services) {
29
29
  kind: 'exact',
30
30
  path: '/dsh-agent-orchestrator/status',
31
31
  handler: (req, res) => {
32
+ if (req.method !== 'GET') {
33
+ return jsonReply(res, { error: 'Method not allowed' }, 405)
34
+ }
32
35
  jsonReply(res, {
33
36
  activePipelines: store.getActivePipelines(),
34
37
  metrics: store.getMetrics(),
@@ -112,6 +115,10 @@ export function registerOrchestratorRoutes(ctx, services) {
112
115
  kind: 'prefix',
113
116
  path: '/dsh-agent-orchestrator/pipeline',
114
117
  handler: (req, res) => {
118
+ if (req.method !== 'GET') {
119
+ return jsonReply(res, { error: 'Method not allowed' }, 405)
120
+ }
121
+ if (rejectUntrustedRequest(req, res)) return
115
122
  const parts = req.url.split('?')[0].split('/')
116
123
  const pipelineId = parts[parts.length - 1]
117
124
  const pipeline = store.getPipeline(pipelineId)
@@ -140,6 +147,7 @@ export function registerOrchestratorRoutes(ctx, services) {
140
147
  }
141
148
 
142
149
  const pipelineId = body.pipelineId
150
+ runner?.cancelPipeline?.(pipelineId)
143
151
  const p = store.getPipeline(pipelineId)
144
152
  if (p && (p.status === 'running' || p.status === 'pending')) {
145
153
  store.updatePipeline(pipelineId, {
@@ -178,6 +186,10 @@ export function registerOrchestratorRoutes(ctx, services) {
178
186
  await updateConfig(body)
179
187
  return jsonReply(res, { success: true, config: getConfig() })
180
188
  }
189
+ if (req.method !== 'GET') {
190
+ return jsonReply(res, { error: 'Method not allowed' }, 405)
191
+ }
192
+ if (rejectUntrustedRequest(req, res)) return
181
193
  jsonReply(res, { config: getConfig() })
182
194
  },
183
195
  }), 'dsh-agent-orchestrator: config route')
@@ -187,6 +199,9 @@ export function registerOrchestratorRoutes(ctx, services) {
187
199
  kind: 'exact',
188
200
  path: '/dsh-agent-orchestrator/models',
189
201
  handler: async (req, res) => {
202
+ if (req.method !== 'GET') {
203
+ return jsonReply(res, { error: 'Method not allowed' }, 405)
204
+ }
190
205
  const selection = readModelSelection(ctx)
191
206
  const catalog = await fetchModelCatalog(ctx, {
192
207
  allowedRoutes: selection.allowedRoutes,
@@ -204,6 +219,9 @@ export function registerOrchestratorRoutes(ctx, services) {
204
219
  kind: 'exact',
205
220
  path: '/dsh-agent-orchestrator/catalog/models',
206
221
  handler: async (req, res) => {
222
+ if (req.method !== 'GET') {
223
+ return jsonReply(res, { error: 'Method not allowed' }, 405)
224
+ }
207
225
  try {
208
226
  const urlObj = new URL(req.url, 'http://localhost')
209
227
  const provider = urlObj.searchParams.get('provider') || undefined
@@ -225,6 +243,10 @@ export function registerOrchestratorRoutes(ctx, services) {
225
243
  kind: 'exact',
226
244
  path: '/dsh-agent-orchestrator/snapshots',
227
245
  handler: (req, res) => {
246
+ if (req.method !== 'GET') {
247
+ return jsonReply(res, { error: 'Method not allowed' }, 405)
248
+ }
249
+ if (rejectUntrustedRequest(req, res)) return
228
250
  try {
229
251
  const snapshots = snapshotManager.listSnapshots()
230
252
  jsonReply(res, { snapshots, total: snapshots.length })
package/lib/store.js CHANGED
@@ -12,9 +12,10 @@ export class OrchestratorStore {
12
12
  constructor(options = {}) {
13
13
  const opts = typeof options === 'string' ? { storageDir: options } : (options || {})
14
14
  const baseDir = opts.storageDir || join(homedir(), '.dsh')
15
- this.storagePath = join(baseDir, 'orchestrator-pipelines.json')
15
+ this.storagePath = opts.storagePath || join(baseDir, 'orchestrator-pipelines.json')
16
16
  this.logger = opts.logger || null
17
17
  this.pipelines = new Map()
18
+ this._saveTimer = null
18
19
  this.globalMetrics = {
19
20
  totalPipelines: 0,
20
21
  completedPipelines: 0,
@@ -55,12 +56,23 @@ export class OrchestratorStore {
55
56
  globalMetrics: this.globalMetrics,
56
57
  updatedAt: Date.now(),
57
58
  }
58
- writeFileSync(this.storagePath, JSON.stringify(serialized, null, 2), 'utf8')
59
+ writeFileSync(this.storagePath, JSON.stringify(serialized), 'utf8')
59
60
  } catch (e) {
60
61
  if (this.logger?.warn) this.logger.warn('[dsh-agent-orchestrator] Store save warning:', e?.message || e)
61
62
  }
62
63
  }
63
64
 
65
+ saveDebounced(delayMs = 150) {
66
+ if (this._saveTimer) return
67
+ this._saveTimer = setTimeout(() => {
68
+ this._saveTimer = null
69
+ this.save()
70
+ }, delayMs)
71
+ if (this._saveTimer && typeof this._saveTimer.unref === 'function') {
72
+ this._saveTimer.unref()
73
+ }
74
+ }
75
+
64
76
  recordPipeline(pipelineData) {
65
77
  this.pipelines.set(pipelineData.pipelineId, pipelineData)
66
78
  this.globalMetrics.totalPipelines++
@@ -107,7 +119,7 @@ export class OrchestratorStore {
107
119
  }
108
120
  }
109
121
 
110
- this.save()
122
+ this.saveDebounced()
111
123
  return updated
112
124
  }
113
125
 
@@ -127,6 +139,7 @@ export class OrchestratorStore {
127
139
  p.durationMs = summary.durationMs || 0
128
140
  p.stateMap = summary.stateMap || {}
129
141
  p.artifacts = summary.artifacts || {}
142
+ if (summary.error) p.error = summary.error
130
143
 
131
144
  if (summary.success) {
132
145
  this.globalMetrics.completedPipelines++
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-agent-orchestrator",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Multi-agent task decomposition, DAG workflow orchestration, and prompt caching optimizer for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",