@goodandready/dsh-agent-orchestrator 0.1.6

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.
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Dispatch Snapshots & One-Click Save as Preset Engine (Issue #109).
3
+ * Security Hardened against Path Traversal (Issue #135).
4
+ * Migrated to ctx.logger (Issue #130).
5
+ *
6
+ * Implements:
7
+ * 1. Atomic snapshot storage in ~/.dsh/orchestrator-snapshots/ (or configured dir).
8
+ * 2. FIFO retention policy with a 200-item hard ceiling.
9
+ * 3. Atomic writes via .tmp files and atomic rename to prevent corruption.
10
+ * 4. Save as Preset method for turning any completed dispatch run into a reusable preset.
11
+ * 5. Strict path traversal verification and snapshot ID sanitization.
12
+ */
13
+
14
+ import fs from 'fs'
15
+ import path from 'path'
16
+ import os from 'os'
17
+ import { randomUUID } from 'crypto'
18
+
19
+ export const DEFAULT_MAX_SNAPSHOTS = 200
20
+
21
+ export class SnapshotManager {
22
+ constructor({ baseDir, maxSnapshots = DEFAULT_MAX_SNAPSHOTS, logger } = {}) {
23
+ this.baseDir = baseDir || path.join(os.homedir(), '.dsh', 'orchestrator-snapshots')
24
+ this.maxSnapshots = maxSnapshots
25
+ this.logger = logger || null
26
+ this._ensureDir()
27
+ }
28
+
29
+ _ensureDir() {
30
+ try {
31
+ if (!fs.existsSync(this.baseDir)) {
32
+ fs.mkdirSync(this.baseDir, { recursive: true })
33
+ }
34
+ } catch (err) {
35
+ if (this.logger?.error) {
36
+ this.logger.error(`[SnapshotManager] Failed to create baseDir ${this.baseDir}:`, err?.message || err)
37
+ }
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Resolves and verifies that a snapshotId stays strictly within baseDir.
43
+ * @param {string} snapshotId
44
+ * @returns {string} Absolute resolved file path
45
+ * @throws {Error} If snapshotId contains illegal characters, path separators, or escapes baseDir
46
+ */
47
+ _pathFor(snapshotId) {
48
+ if (!snapshotId || typeof snapshotId !== 'string') {
49
+ throw new Error('Invalid snapshotId: must be a non-empty string')
50
+ }
51
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(snapshotId)) {
52
+ throw new Error(`Invalid snapshotId: "${snapshotId}" contains illegal characters or invalid length`)
53
+ }
54
+ const baseResolved = path.resolve(this.baseDir)
55
+ const resolved = path.resolve(this.baseDir, `${snapshotId}.json`)
56
+ if (!resolved.startsWith(baseResolved + path.sep)) {
57
+ throw new Error(`Path traversal attempt detected in snapshotId: "${snapshotId}"`)
58
+ }
59
+ return resolved
60
+ }
61
+
62
+ /**
63
+ * Lists all recorded snapshots sorted newest to oldest.
64
+ * @returns {Array<object>}
65
+ */
66
+ listSnapshots() {
67
+ this._ensureDir()
68
+ try {
69
+ const files = fs.readdirSync(this.baseDir)
70
+ const snapshots = []
71
+
72
+ for (const file of files) {
73
+ if (!file.endsWith('.json')) continue
74
+ const filePath = path.join(this.baseDir, file)
75
+ try {
76
+ const content = fs.readFileSync(filePath, 'utf8')
77
+ const data = JSON.parse(content)
78
+ snapshots.push(data)
79
+ } catch (err) {
80
+ // Ignore corrupt files during listing
81
+ if (this.logger?.warn) {
82
+ this.logger.warn(`[SnapshotManager] Skipping invalid snapshot file ${file}:`, err?.message || err)
83
+ }
84
+ }
85
+ }
86
+
87
+ // Sort descending by timestamp / createdAt
88
+ snapshots.sort((a, b) => {
89
+ const timeA = new Date(a.createdAt || a.timestamp || 0).getTime()
90
+ const timeB = new Date(b.createdAt || b.timestamp || 0).getTime()
91
+ return timeB - timeA
92
+ })
93
+
94
+ return snapshots
95
+ } catch (err) {
96
+ if (this.logger?.error) {
97
+ this.logger.error('[SnapshotManager] Error listing snapshots:', err?.message || err)
98
+ }
99
+ return []
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Retrieves a snapshot by ID with path traversal protection.
105
+ * @param {string} snapshotId
106
+ * @returns {object|null}
107
+ */
108
+ getSnapshot(snapshotId) {
109
+ if (!snapshotId) return null
110
+ let filePath
111
+ try {
112
+ filePath = this._pathFor(snapshotId)
113
+ } catch (err) {
114
+ if (this.logger?.warn) {
115
+ this.logger.warn(`[SnapshotManager] Invalid snapshotId rejected: ${err?.message || err}`)
116
+ }
117
+ return null
118
+ }
119
+
120
+ if (!fs.existsSync(filePath)) return null
121
+ try {
122
+ const content = fs.readFileSync(filePath, 'utf8')
123
+ return JSON.parse(content)
124
+ } catch (err) {
125
+ if (this.logger?.error) {
126
+ this.logger.error(`[SnapshotManager] Error reading snapshot ${snapshotId}:`, err?.message || err)
127
+ }
128
+ return null
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Creates a snapshot atomically with path traversal protection.
134
+ * @param {object} snapshotData
135
+ * @returns {object} The created snapshot metadata
136
+ */
137
+ createSnapshot(snapshotData = {}) {
138
+ this._ensureDir()
139
+
140
+ const snapshotId = snapshotData.id || `snap-${Date.now()}-${randomUUID().slice(0, 8)}`
141
+ const filePath = this._pathFor(snapshotId)
142
+ const nowIso = new Date().toISOString()
143
+
144
+ const record = {
145
+ id: snapshotId,
146
+ createdAt: nowIso,
147
+ title: snapshotData.title || `Snapshot ${snapshotId}`,
148
+ scenarioId: snapshotData.scenarioId || 'custom',
149
+ status: snapshotData.status || 'completed',
150
+ modelOverrides: snapshotData.modelOverrides || {},
151
+ stages: snapshotData.stages || [],
152
+ roles: snapshotData.roles || [],
153
+ totalCostUsd: snapshotData.totalCostUsd || 0,
154
+ durationMs: snapshotData.durationMs || 0,
155
+ metadata: snapshotData.metadata || {},
156
+ }
157
+
158
+ const tmpPath = path.join(this.baseDir, `${snapshotId}.tmp-${Date.now()}`)
159
+
160
+ try {
161
+ fs.writeFileSync(tmpPath, JSON.stringify(record, null, 2), 'utf8')
162
+ fs.renameSync(tmpPath, filePath)
163
+ } catch (err) {
164
+ try {
165
+ if (fs.existsSync(tmpPath)) fs.unlinkSync(tmpPath)
166
+ } catch (_) {
167
+ // Safe ignore
168
+ }
169
+ throw err
170
+ }
171
+
172
+ // Enforce FIFO retention policy: if file count > maxSnapshots, prune oldest
173
+ this._enforceRetention()
174
+
175
+ return record
176
+ }
177
+
178
+ /**
179
+ * Prunes oldest snapshot files when total exceeds maxSnapshots ceiling.
180
+ */
181
+ _enforceRetention() {
182
+ try {
183
+ const files = fs.readdirSync(this.baseDir).filter((f) => f.endsWith('.json'))
184
+ if (files.length <= this.maxSnapshots) return
185
+
186
+ const fileStats = files.map((file) => {
187
+ const fullPath = path.join(this.baseDir, file)
188
+ const stat = fs.statSync(fullPath)
189
+ return { file, fullPath, mtime: stat.mtimeMs }
190
+ })
191
+
192
+ // Sort ascending (oldest first)
193
+ fileStats.sort((a, b) => a.mtime - b.mtime)
194
+
195
+ const removeCount = fileStats.length - this.maxSnapshots
196
+ for (let i = 0; i < removeCount; i++) {
197
+ try {
198
+ fs.unlinkSync(fileStats[i].fullPath)
199
+ } catch (err) {
200
+ if (this.logger?.warn) {
201
+ this.logger.warn(`[SnapshotManager] Failed to prune old snapshot ${fileStats[i].file}:`, err?.message || err)
202
+ }
203
+ }
204
+ }
205
+ } catch (err) {
206
+ if (this.logger?.warn) {
207
+ this.logger.warn('[SnapshotManager] Retention enforcement error:', err?.message || err)
208
+ }
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Converts a snapshot into a reusable scenario/preset structure.
214
+ * @param {string} snapshotId
215
+ * @param {object} [overrides] Custom name/description
216
+ * @returns {object} Preset configuration object
217
+ */
218
+ saveAsPreset(snapshotId, overrides = {}) {
219
+ this._pathFor(snapshotId)
220
+ const snapshot = this.getSnapshot(snapshotId)
221
+ if (!snapshot) {
222
+ throw new Error(`Snapshot "${snapshotId}" not found`)
223
+ }
224
+
225
+ const presetId = overrides.id || `preset-${snapshot.scenarioId || 'custom'}-${Date.now().toString(36)}`
226
+ const presetName = overrides.name || overrides.title || `Preset from ${snapshot.title || snapshotId}`
227
+ const presetDescription = overrides.description || `Generated from snapshot ${snapshotId} at ${new Date().toISOString()}`
228
+
229
+ return {
230
+ id: presetId,
231
+ name: presetName,
232
+ description: presetDescription,
233
+ scenarioId: snapshot.scenarioId || 'custom',
234
+ stages: snapshot.stages || [],
235
+ modelOverrides: snapshot.modelOverrides || {},
236
+ createdAt: new Date().toISOString(),
237
+ sourceSnapshotId: snapshotId,
238
+ }
239
+ }
240
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Deterministic Max-Tokens Watchdog.
3
+ *
4
+ * Implements Issue #75:
5
+ * 1. Turn-end interception when output is truncated due to max-tokens limit.
6
+ * 2. Flush Checkpoint: flushes state to prevent lost progress.
7
+ * 3. Continue-Once Guarantee: strictly allows at most ONE automated continuation per execution.
8
+ * 4. Cycle Protection: if truncation occurs again, halts with MaxTokensLoopError demanding
9
+ * subtask decomposition, preventing infinite loops and token drain.
10
+ */
11
+
12
+ export class MaxTokensLoopError extends Error {
13
+ constructor(message, { executionId, continuationCount = 1 } = {}) {
14
+ super(message)
15
+ this.name = 'MaxTokensLoopError'
16
+ this.executionId = executionId
17
+ this.continuationCount = continuationCount
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Checks whether a given stop reason or response indicates token limit exhaustion.
23
+ *
24
+ * @param {string|object} stopReason
25
+ * @returns {boolean}
26
+ */
27
+ export function isMaxTokensTruncated(stopReason) {
28
+ if (!stopReason) return false
29
+ if (typeof stopReason === 'object') {
30
+ if (stopReason.kind === 'max-tokens' || stopReason.kind === 'length') return true
31
+ if (stopReason.stopReason === 'max_tokens' || stopReason.stopReason === 'length') return true
32
+ return false
33
+ }
34
+
35
+ const s = String(stopReason).toLowerCase()
36
+ return s === 'max-tokens' || s === 'max_tokens' || s === 'length' || s.includes('token_limit')
37
+ }
38
+
39
+ /**
40
+ * Wraps a turn / worker execution with Deterministic max-tokens Watchdog.
41
+ *
42
+ * @param {object} params
43
+ * @param {function} params.executeTurn Function (continuationCount) => Promise<{ output: string, stopReason: string, raw?: any }>
44
+ * @param {function} [params.flushCheckpoint] Callback to persist session checkpoint before continuation
45
+ * @param {string} [params.continuationPrompt='Continue generation precisely from where you stopped.']
46
+ * @param {string} [params.executionId]
47
+ * @returns {Promise<{ output: string, stopReason: string, continuationCount: number, wasTruncated: boolean }>}
48
+ */
49
+ export async function runWithMaxTokensWatchdog({
50
+ executeTurn,
51
+ flushCheckpoint,
52
+ continuationPrompt = 'Continue generation precisely from where you stopped.',
53
+ executionId = 'turn',
54
+ logger = null,
55
+ }) {
56
+ let continuationCount = 0
57
+ let combinedOutput = ''
58
+
59
+ // Step 1: Initial turn
60
+ const initialResult = await executeTurn(0, '')
61
+ combinedOutput = initialResult.output || ''
62
+
63
+ if (!isMaxTokensTruncated(initialResult.stopReason)) {
64
+ return {
65
+ output: combinedOutput,
66
+ stopReason: initialResult.stopReason || 'completed',
67
+ continuationCount: 0,
68
+ wasTruncated: false,
69
+ }
70
+ }
71
+
72
+ // Step 2: First truncation detected -> trigger single automated continuation
73
+ continuationCount = 1
74
+
75
+ if (typeof flushCheckpoint === 'function') {
76
+ try {
77
+ await flushCheckpoint()
78
+ } catch (err) {
79
+ if (logger?.debug) logger.debug('[MaxTokensWatchdog] flushCheckpoint warning:', err?.message || err)
80
+ }
81
+ }
82
+
83
+ const secondResult = await executeTurn(continuationCount, continuationPrompt)
84
+ combinedOutput += (combinedOutput ? '\n' : '') + (secondResult.output || '')
85
+
86
+ // Step 3: Cycle Protection Check (Continue-Once Guarantee)
87
+ if (isMaxTokensTruncated(secondResult.stopReason)) {
88
+ throw new MaxTokensLoopError(
89
+ `[MaxTokensWatchdog] Task "${executionId}" truncated by token limit twice. ` +
90
+ `Automated continuation aborted to prevent infinite token consumption. Please decompose the task into smaller subtasks.`,
91
+ { executionId, continuationCount }
92
+ )
93
+ }
94
+
95
+ return {
96
+ output: combinedOutput,
97
+ stopReason: secondResult.stopReason || 'completed',
98
+ continuationCount,
99
+ wasTruncated: true,
100
+ }
101
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Worker Pool Dispatcher for DSH Multi-Agent Orchestrator.
3
+ *
4
+ * Dispatches stage executions to LLM instances with:
5
+ * - Canonical prefix alignment (Prompt Caching maximization)
6
+ * - Transient retry handling (HTTP 429, network timeouts)
7
+ * - Streaming token collection
8
+ * - Telemetry recording (Cache Hit ratio, duration, token usage)
9
+ */
10
+
11
+ import {
12
+ buildStaticBaseAnchor,
13
+ buildSharedTaskAnchor,
14
+ formatCumulativeArtifacts,
15
+ assembleAgentMessages,
16
+ extractCacheMetrics,
17
+ } from './cache-prefixer.js'
18
+ import { resolveSpecialistModel } from './model-selection.js'
19
+
20
+ /**
21
+ * Invokes LLM with exponential backoff on recoverable transient errors.
22
+ */
23
+ export async function callWithTransientRetry(callLlmFn, args, maxRetries = 2, baseDelayMs = 1500) {
24
+ let attempt = 0
25
+ while (true) {
26
+ try {
27
+ return await callLlmFn(args)
28
+ } catch (err) {
29
+ attempt++
30
+ const msg = err?.message || String(err)
31
+ const isTransient = /429|rate limit|quota|502|503|504|econnreset|etimedout|socket hang up/i.test(msg)
32
+ if (attempt <= maxRetries && isTransient) {
33
+ const delay = baseDelayMs * Math.pow(2, attempt - 1)
34
+ await new Promise((r) => setTimeout(r, delay))
35
+ continue
36
+ }
37
+ throw err
38
+ }
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Executes a single pipeline stage with full prompt cache alignment.
44
+ *
45
+ * @param {object} stage Stage definition
46
+ * @param {object} context Execution context
47
+ * @param {object} context.pipeline Global pipeline metadata
48
+ * @param {object} context.agentRole Configured role for this stage
49
+ * @param {object} context.config Plugin configuration
50
+ * @param {function} context.callLlm Function to call DSH LLM
51
+ * @param {object} context.upstreamOutputs Prior stage outputs map
52
+ * @param {function} [context.onStreamDelta] Streaming callback
53
+ * @returns {Promise<{ output: string, metrics: object }>}
54
+ */
55
+ export async function executeStageWorker(stage, context) {
56
+ const { pipeline, agentRole, config = {}, callLlm, upstreamOutputs = {}, onStreamDelta } = context
57
+
58
+ if (typeof callLlm !== 'function') {
59
+ throw new Error('callLlm function is required to execute stage worker')
60
+ }
61
+
62
+ // 1. Build Layer 1: Static Base Anchor (> 1024 tokens)
63
+ const baseAnchor = buildStaticBaseAnchor({
64
+ projectType: config.projectType || 'dsh-plugin',
65
+ customRules: config.customRules || '',
66
+ })
67
+
68
+ // 2. Build Layer 2: Shared Task Anchor
69
+ const taskAnchor = buildSharedTaskAnchor({
70
+ id: pipeline.pipelineId,
71
+ title: pipeline.taskTitle,
72
+ description: pipeline.taskDescription,
73
+ repo: pipeline.repo,
74
+ issueUrl: pipeline.issueUrl,
75
+ scenario: pipeline.scenarioId,
76
+ })
77
+
78
+ // 3. Build Layer 3: Cumulative Upstream Artifacts (Append-Only)
79
+ const priorArtifactsList = []
80
+ for (const [depId, artContent] of Object.entries(upstreamOutputs)) {
81
+ priorArtifactsList.push({
82
+ stageId: depId,
83
+ roleId: stage.dependsOn?.find((d) => d === depId) || 'upstream',
84
+ output: artContent || '',
85
+ })
86
+ }
87
+ const cumulativeArtifacts = formatCumulativeArtifacts(priorArtifactsList)
88
+
89
+ // 4. Assemble canonical prompt (Layer 1 + 2 + 3 in System, Layer 4 in User)
90
+ const messages = assembleAgentMessages({
91
+ baseAnchor,
92
+ taskAnchor,
93
+ cumulativeArtifacts,
94
+ agentRole,
95
+ currentStage: stage,
96
+ })
97
+
98
+ const { provider, model, warning, maxTokens: resolvedMaxTokens, reasoningEffort } = resolveSpecialistModel({
99
+ roleModel: agentRole.defaultModel,
100
+ fallbackModel: { provider: 'deepseek', model: 'deepseek-chat' },
101
+ allowedRoutes: context.allowedRoutes,
102
+ capability: agentRole.capability,
103
+ reasoningEffort: agentRole.reasoningEffort,
104
+ })
105
+
106
+ const log = context.logger || context.config?.logger || null
107
+ if (warning && log?.warn) {
108
+ log.warn(`[dsh-agent-orchestrator] ${warning}`)
109
+ }
110
+
111
+ const temperature = agentRole.temperature ?? 0.3
112
+ const maxTokens = resolvedMaxTokens ?? agentRole.maxTokens ?? 4096
113
+
114
+ // 5. Execute LLM call with retry
115
+ const result = await callWithTransientRetry(
116
+ callLlm,
117
+ {
118
+ provider,
119
+ model,
120
+ messages,
121
+ temperature,
122
+ maxTokens,
123
+ reasoningEffort,
124
+ onStreamDelta,
125
+ },
126
+ 2,
127
+ 1200
128
+ )
129
+
130
+ const outputText = result.text || result.content || ''
131
+ const rawUsage = result.usage || {}
132
+ const cacheMetrics = extractCacheMetrics(rawUsage)
133
+
134
+ return {
135
+ output: outputText,
136
+ metrics: {
137
+ ...cacheMetrics,
138
+ model: `${provider}:${model}`,
139
+ stageId: stage.id,
140
+ roleId: stage.roleId,
141
+ },
142
+ }
143
+ }