@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.
- package/LICENSE +22 -0
- package/README.md +184 -0
- package/README.ru.md +86 -0
- package/README.zh.md +64 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +1374 -0
- package/lib/http-guard.js +166 -0
- package/lib/index.js +950 -0
- package/lib/integrations/kanban-bridge.js +102 -0
- package/lib/pipeline/cache-prefixer.js +226 -0
- package/lib/pipeline/concurrency-gate.js +110 -0
- package/lib/pipeline/dag-engine.js +290 -0
- package/lib/pipeline/decision-trace.js +124 -0
- package/lib/pipeline/decomposer.js +102 -0
- package/lib/pipeline/delegation.js +625 -0
- package/lib/pipeline/guidance.js +94 -0
- package/lib/pipeline/intent.js +98 -0
- package/lib/pipeline/intersection.js +235 -0
- package/lib/pipeline/model-selection.js +742 -0
- package/lib/pipeline/preset-sync.js +84 -0
- package/lib/pipeline/scenarios.js +468 -0
- package/lib/pipeline/session-lifecycle.js +520 -0
- package/lib/pipeline/snapshots.js +240 -0
- package/lib/pipeline/token-watchdog.js +101 -0
- package/lib/pipeline/worker-pool.js +143 -0
- package/lib/routes.js +277 -0
- package/lib/store.js +176 -0
- package/package.json +94 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kanban & Gitea Integration Bridge for DSH Multi-Agent Orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Interfaces with @goodandready/dsh-kanban and Gitea issue tracking:
|
|
5
|
+
* - Updates task cards, checklist milestones, and agent badges
|
|
6
|
+
* - Auto-advances cards between workflow columns (e.g. progress -> review -> done)
|
|
7
|
+
* - Safe degradation if dsh-kanban is not installed
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export class KanbanBridge {
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.port = options.port || 3080
|
|
13
|
+
this.fetchImpl = options.fetchImpl || globalThis.fetch
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
_url(path) {
|
|
17
|
+
return `http://127.0.0.1:${this.port}${path}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async getTask(taskId) {
|
|
21
|
+
if (!taskId) return null
|
|
22
|
+
try {
|
|
23
|
+
const res = await this.fetchImpl(this._url(`/dsh-kanban/task/${encodeURIComponent(taskId)}`), {
|
|
24
|
+
signal: AbortSignal.timeout(3000),
|
|
25
|
+
})
|
|
26
|
+
if (!res.ok) return null
|
|
27
|
+
return await res.json()
|
|
28
|
+
} catch {
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async updateChecklist(taskId, items = []) {
|
|
34
|
+
if (!taskId || !Array.isArray(items)) return false
|
|
35
|
+
try {
|
|
36
|
+
const res = await this.fetchImpl(this._url(`/dsh-kanban/task/${encodeURIComponent(taskId)}/checklist`), {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
body: JSON.stringify({ items }),
|
|
40
|
+
signal: AbortSignal.timeout(3000),
|
|
41
|
+
})
|
|
42
|
+
return res.ok
|
|
43
|
+
} catch {
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async moveTask(taskId, columnId) {
|
|
49
|
+
if (!taskId || !columnId) return false
|
|
50
|
+
try {
|
|
51
|
+
const res = await this.fetchImpl(this._url(`/dsh-kanban/task/${encodeURIComponent(taskId)}/move`), {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { 'Content-Type': 'application/json' },
|
|
54
|
+
body: JSON.stringify({ columnId }),
|
|
55
|
+
signal: AbortSignal.timeout(3000),
|
|
56
|
+
})
|
|
57
|
+
return res.ok
|
|
58
|
+
} catch {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async postComment(taskId, commentText) {
|
|
64
|
+
if (!taskId || !commentText) return false
|
|
65
|
+
try {
|
|
66
|
+
const res = await this.fetchImpl(this._url(`/dsh-kanban/task/${encodeURIComponent(taskId)}/comments`), {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { 'Content-Type': 'application/json' },
|
|
69
|
+
body: JSON.stringify({ body: commentText, author: 'orchestrator' }),
|
|
70
|
+
signal: AbortSignal.timeout(3000),
|
|
71
|
+
})
|
|
72
|
+
return res.ok
|
|
73
|
+
} catch {
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Syncs stage completion to a linked Kanban task.
|
|
80
|
+
*/
|
|
81
|
+
async syncStageProgress(taskId, stage, allStages, targetColumn = 'Done') {
|
|
82
|
+
if (!taskId) return
|
|
83
|
+
|
|
84
|
+
const checklistItems = allStages.map((s) => ({
|
|
85
|
+
id: s.id,
|
|
86
|
+
text: `${s.name || s.id} (${s.roleName || s.roleId})`,
|
|
87
|
+
done: s.status === 'completed',
|
|
88
|
+
}))
|
|
89
|
+
|
|
90
|
+
await this.updateChecklist(taskId, checklistItems)
|
|
91
|
+
|
|
92
|
+
// If all stages complete, advance card to configured target column
|
|
93
|
+
const allDone = allStages.every((s) => s.status === 'completed')
|
|
94
|
+
if (allDone) {
|
|
95
|
+
await this.moveTask(taskId, targetColumn)
|
|
96
|
+
await this.postComment(
|
|
97
|
+
taskId,
|
|
98
|
+
`🚀 **Multi-Agent Orchestrator Pipeline Completed Successfully!**\nAll stages finished. Card moved to column "${targetColumn}".`
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt Caching & Prefix Canonicalization Optimizer for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* Guarantees byte-level prefix invariance for multi-agent workflows:
|
|
5
|
+
* 1. Layer 1: Canonical Static Base Anchor (>= 1024 tokens) containing ecosystem guidelines,
|
|
6
|
+
* coding standards, and tool contracts.
|
|
7
|
+
* 2. Layer 2: Shared Task Anchor containing user prompt, issue metadata, and global plan.
|
|
8
|
+
* 3. Layer 3: Cumulative Upstream Artifacts (Append-Only sequence) preserving 100% KV-cache continuity.
|
|
9
|
+
* 4. Layer 4: Role-specific Execution Directive (Role prompt, skills, subtask instructions).
|
|
10
|
+
*
|
|
11
|
+
* Extracts and calculates prompt_cache_hit_tokens telemetry.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const MIN_CACHE_ANCHOR_TOKENS = 1024
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Generates a stable, canonical base anchor that exceeds 1024 tokens.
|
|
18
|
+
* Byte-for-byte identical across all agent calls sharing the same model.
|
|
19
|
+
*
|
|
20
|
+
* @param {object} [config={}]
|
|
21
|
+
* @param {string} [config.projectType='dsh-plugin']
|
|
22
|
+
* @param {string} [config.customRules='']
|
|
23
|
+
* @returns {string} Static anchor block
|
|
24
|
+
*/
|
|
25
|
+
export function buildStaticBaseAnchor(config = {}) {
|
|
26
|
+
const projectType = config.projectType || 'dsh-plugin'
|
|
27
|
+
const customRules = config.customRules || ''
|
|
28
|
+
|
|
29
|
+
// Standard architectural rules for DSH & TypeScript/ESM projects
|
|
30
|
+
return [
|
|
31
|
+
'# CANONICAL REPOSITORY CONVENTIONS AND AGENT PROTOCOL',
|
|
32
|
+
'',
|
|
33
|
+
'## 1. ECOSYSTEM ARCHITECTURE & STRICT BOUNDARIES',
|
|
34
|
+
`- Project Framework: DeepSeek Harness (DSH) Multi-Agent Architecture [Type: ${projectType}].`,
|
|
35
|
+
'- Target Runtime: Node.js >= 20, ECMAScript Modules (ESM) exclusively.',
|
|
36
|
+
'- Package Scope: All internal extensions and plugins follow `@goodandready/<package>` convention.',
|
|
37
|
+
'- Identity Invariance: The package name must match in package.json, cordis.patch.yml, and client __ModuleLoader__.load({ id }).',
|
|
38
|
+
'- Bundle Size Gate: Maximum file size must not exceed 262,144 bytes (256 KiB); maintain below 250 KiB threshold.',
|
|
39
|
+
'- Style Isolation: Every client style must declare `data-dsh-plugin="<plugin-id>"` and use unique class prefixes.',
|
|
40
|
+
'- Color Theming: Use exclusively system CSS theme variables (e.g., `--dsw-alias-bg-layer-3`, `--dsw-alias-border-l2`, `--dsw-alias-label-primary`). Never hardcode hex/rgb colors.',
|
|
41
|
+
'- Localization Protocol: Client strings must register via `ctx.effect(() => ctx.locale.register(NS, { en, zh, ru }), ...)` with proper disposers.',
|
|
42
|
+
'- State Disposer Invariance: All side-effects, DOM manipulations, intervals, and event subscriptions must return reliable cleanup functions.',
|
|
43
|
+
'- Safe Service Injection: Declare all required cordis services in `module.exports.inject = [...]` before accessing them in `apply(ctx)`.',
|
|
44
|
+
'',
|
|
45
|
+
'## 2. ENGINEERING EXECUTION STANDARDS & CODE QUALITY',
|
|
46
|
+
'- Principle of Least Astonishment (POLA): Solutions must be direct, minimal, and devoid of speculative abstractions.',
|
|
47
|
+
'- Standard Library First: Leverage native Node.js / Web APIs (`node:path`, `node:crypto`, `node:fs/promises`, `URL`, `AbortController`) before adding external dependencies.',
|
|
48
|
+
'- Deterministic Behavior: All algorithms, parsers, and graph planners must be idempotent and testable without active network or daemon daemons.',
|
|
49
|
+
'- Pure Domain Logic: Business logic, graph solvers, and formatting utilities must remain decoupled from Cordis or UI layers.',
|
|
50
|
+
'- Full Output Enforcement: Never truncate code, never omit methods with placeholders (such as `// ... rest of code`), and output complete implementations.',
|
|
51
|
+
'- Defensive Error Handling: Catch transient socket/rate-limit anomalies gracefully; propagate structural blockers explicitly to orchestration DAG.',
|
|
52
|
+
'- Zero Dead Exports: Every exported function or constant must have an active consumer, caller, or formal unit test coverage.',
|
|
53
|
+
'- Safe Concurrency: Asynchronous tasks must run behind bounded concurrency gates, avoiding unbounded background worker pool resource exhaustion.',
|
|
54
|
+
'- Secure Subprocess Boundaries: Avoid shell injection by strictly sanitizing parameters and using structured array arguments in process spawns.',
|
|
55
|
+
'',
|
|
56
|
+
'## 3. MULTI-AGENT DAG COORDINATION CONTRACT & LIFECYCLE',
|
|
57
|
+
'- Directed Acyclic Graph: Tasks execute in topological order with strict dependency resolution and cycle detection.',
|
|
58
|
+
'- Upstream Data Invariance: Inputs received from predecessor stages are read-only immutable contracts.',
|
|
59
|
+
'- Downstream Artifact Delivery: Outputs must be clearly demarcated with machine-parseable headers and human-readable summaries.',
|
|
60
|
+
'- Role Specialization: Agents must focus strictly on their designated domain (architecture, spec, ui, code, tests, or documentation).',
|
|
61
|
+
'- Decision Ledger Tracking: Subagent routing and decisions must emit structured audit events to the decision ledger for traceability.',
|
|
62
|
+
'- Stage Validation Gate: Each stage outcome must be validated against schema and acceptance constraints prior to marking stage as complete.',
|
|
63
|
+
'- Rejection & Rework Protocol: Failed or incomplete stage outputs must trigger targeted rework with explicit iteration feedback.',
|
|
64
|
+
'- Execution Isolation: Child subagent sessions operate within isolated execution bubbles with strictly scoped tool sets.',
|
|
65
|
+
customRules ? `\n## 4. PROJECT-SPECIFIC OVERRIDES\n${customRules}\n` : '',
|
|
66
|
+
'',
|
|
67
|
+
'## 5. PROMPT CACHING & PREFIX INTEGRITY NOTICE',
|
|
68
|
+
'This system prefix is intentionally structured and token-padded to optimize KV-cache reuse across agent calls.',
|
|
69
|
+
'Do not inject dynamic timestamps, random session identifiers, or non-deterministic variables prior to this anchor.',
|
|
70
|
+
'All LLM calls sharing the same base model will reuse this exact prefix block directly from the hardware KV-cache.',
|
|
71
|
+
'The anchor length is calibrated to meet or exceed MIN_CACHE_ANCHOR_TOKENS (1024 tokens) to satisfy provider cache boundaries.',
|
|
72
|
+
'================================================================================',
|
|
73
|
+
].join('\n')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Formats the shared task anchor (Layer 2).
|
|
78
|
+
*/
|
|
79
|
+
export function buildSharedTaskAnchor(task = {}) {
|
|
80
|
+
const taskId = task.id || 'unassigned-task'
|
|
81
|
+
const title = task.title || 'Untitled Task'
|
|
82
|
+
const description = task.description || task.body || ''
|
|
83
|
+
const issueUrl = task.issueUrl || ''
|
|
84
|
+
const repo = task.repo || ''
|
|
85
|
+
const scenario = task.scenario || 'standard'
|
|
86
|
+
|
|
87
|
+
return [
|
|
88
|
+
'## GLOBAL WORKFLOW TASK ANCHOR',
|
|
89
|
+
`- Task Reference ID: ${taskId}`,
|
|
90
|
+
`- Repository / Workspace: ${repo || 'local-context'}`,
|
|
91
|
+
issueUrl ? `- Gitea Issue: ${issueUrl}` : '',
|
|
92
|
+
`- Pipeline Scenario: ${scenario}`,
|
|
93
|
+
`- Objective Title: ${title}`,
|
|
94
|
+
'',
|
|
95
|
+
'### Task Specification:',
|
|
96
|
+
description.trim(),
|
|
97
|
+
'',
|
|
98
|
+
'--------------------------------------------------------------------------------',
|
|
99
|
+
].filter(Boolean).join('\n')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Formats prior stage artifacts into an append-only cumulative sequence (Layer 3).
|
|
104
|
+
* Preserves the exact prefix of all prior stages so subsequent LLM calls hit the cache.
|
|
105
|
+
*
|
|
106
|
+
* @param {Array<{ stageId: string, roleId: string, output: string }>} stageArtifacts
|
|
107
|
+
* @returns {string} Formatted cumulative block
|
|
108
|
+
*/
|
|
109
|
+
export function formatCumulativeArtifacts(stageArtifacts = []) {
|
|
110
|
+
if (!Array.isArray(stageArtifacts) || stageArtifacts.length === 0) {
|
|
111
|
+
return ''
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const parts = ['## UPSTREAM STAGE ARTIFACTS (CUMULATIVE CONTEXT)']
|
|
115
|
+
for (const art of stageArtifacts) {
|
|
116
|
+
parts.push(
|
|
117
|
+
`\n### [STAGE OUTPUT: ${art.stageId}] (Role: ${art.roleId || 'unknown'})\n` +
|
|
118
|
+
'```markdown\n' +
|
|
119
|
+
(typeof art.output === 'string' ? art.output.trim() : JSON.stringify(art.output, null, 2)) +
|
|
120
|
+
'\n```'
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
parts.push('\n--------------------------------------------------------------------------------')
|
|
124
|
+
return parts.join('\n')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Assembles the full prompt messages array for an agent execution step.
|
|
129
|
+
* Guarantees that Layer 1, Layer 2, and Layer 3 form an unbroken byte prefix.
|
|
130
|
+
*
|
|
131
|
+
* @param {object} params
|
|
132
|
+
* @param {string} params.baseAnchor Layer 1
|
|
133
|
+
* @param {string} params.taskAnchor Layer 2
|
|
134
|
+
* @param {string} params.cumulativeArtifacts Layer 3
|
|
135
|
+
* @param {object} params.agentRole
|
|
136
|
+
* @param {string} params.agentRole.name
|
|
137
|
+
* @param {string} params.agentRole.id
|
|
138
|
+
* @param {string} params.agentRole.systemPrompt
|
|
139
|
+
* @param {Array<string>} [params.agentRole.skills]
|
|
140
|
+
* @param {object} params.currentStage
|
|
141
|
+
* @param {string} params.currentStage.id
|
|
142
|
+
* @param {string} params.currentStage.name
|
|
143
|
+
* @param {string} params.currentStage.subtaskScope
|
|
144
|
+
* @returns {Array<{ role: string, content: string }>} Messages array
|
|
145
|
+
*/
|
|
146
|
+
export function assembleAgentMessages({
|
|
147
|
+
baseAnchor,
|
|
148
|
+
taskAnchor,
|
|
149
|
+
cumulativeArtifacts,
|
|
150
|
+
agentRole = {},
|
|
151
|
+
currentStage = {},
|
|
152
|
+
}) {
|
|
153
|
+
// System prompt: Shared Base Anchor (L1) + Shared Task Anchor (L2) + Cumulative Artifacts (L3)
|
|
154
|
+
const systemPrefix = [
|
|
155
|
+
baseAnchor,
|
|
156
|
+
'',
|
|
157
|
+
taskAnchor,
|
|
158
|
+
'',
|
|
159
|
+
cumulativeArtifacts,
|
|
160
|
+
].filter(Boolean).join('\n')
|
|
161
|
+
|
|
162
|
+
// User message: Layer 4 (Specific role, skills, subtask directives)
|
|
163
|
+
const skillsList = Array.isArray(agentRole.skills) && agentRole.skills.length > 0
|
|
164
|
+
? agentRole.skills.map((s) => `- ${s}`).join('\n')
|
|
165
|
+
: 'None'
|
|
166
|
+
|
|
167
|
+
const userDirective = [
|
|
168
|
+
`# ACTIVE EXECUTION DIRECTIVE: STAGE [${currentStage.name || currentStage.id}]`,
|
|
169
|
+
'',
|
|
170
|
+
`## Assigned Agent Persona: ${agentRole.name || agentRole.id}`,
|
|
171
|
+
agentRole.systemPrompt ? `### Specialized Role Instructions:\n${agentRole.systemPrompt.trim()}\n` : '',
|
|
172
|
+
'### Active Skills / Capabilities:',
|
|
173
|
+
skillsList,
|
|
174
|
+
'',
|
|
175
|
+
'### Stage Objectives & Scope:',
|
|
176
|
+
(currentStage.subtaskScope || currentStage.name || 'Execute designated stage deliverables.').trim(),
|
|
177
|
+
'',
|
|
178
|
+
'### Delivery Requirements:',
|
|
179
|
+
'1. Produce complete, production-ready deliverables adhering strictly to the repository conventions.',
|
|
180
|
+
'2. Provide clean explanations of architectural choices, interfaces, and test guarantees.',
|
|
181
|
+
'3. Do not duplicate upstream artifacts unless directly refining or referencing them.',
|
|
182
|
+
'4. Begin your response directly with the deliverable content.',
|
|
183
|
+
].filter(Boolean).join('\n')
|
|
184
|
+
|
|
185
|
+
return [
|
|
186
|
+
{ role: 'system', content: systemPrefix },
|
|
187
|
+
{ role: 'user', content: userDirective },
|
|
188
|
+
]
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Extracts and normalizes prompt cache telemetry from LLM usage payloads.
|
|
193
|
+
*
|
|
194
|
+
* @param {object} usage Raw usage object from API stream
|
|
195
|
+
* @returns {object} Normalized metrics { promptTokens, completionTokens, totalTokens, cacheHitTokens, cacheMissTokens, hitRatio, estimatedSavingsPct }
|
|
196
|
+
*/
|
|
197
|
+
export function extractCacheMetrics(usage = {}) {
|
|
198
|
+
const promptTokens = usage.prompt_tokens ?? usage.inputTokens ?? usage.promptTokens ?? 0
|
|
199
|
+
const completionTokens = usage.completion_tokens ?? usage.outputTokens ?? usage.completionTokens ?? 0
|
|
200
|
+
|
|
201
|
+
// Prompt caching fields from DeepSeek, OpenAI, or Anthropic
|
|
202
|
+
const cacheHitTokens = usage.prompt_cache_hit_tokens
|
|
203
|
+
?? usage.cache_read_input_tokens
|
|
204
|
+
?? usage.cached_prompt_tokens
|
|
205
|
+
?? 0
|
|
206
|
+
|
|
207
|
+
const cacheMissTokens = usage.prompt_cache_miss_tokens
|
|
208
|
+
?? usage.cache_creation_input_tokens
|
|
209
|
+
?? Math.max(0, promptTokens - cacheHitTokens)
|
|
210
|
+
|
|
211
|
+
const totalTokens = promptTokens + completionTokens
|
|
212
|
+
const hitRatio = promptTokens > 0 ? (cacheHitTokens / promptTokens) : 0
|
|
213
|
+
|
|
214
|
+
// DeepSeek cache hit is ~90% cheaper than cache miss (0.14$/M vs 1.4$/M)
|
|
215
|
+
const estimatedSavingsPct = Math.round(hitRatio * 90)
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
promptTokens,
|
|
219
|
+
completionTokens,
|
|
220
|
+
totalTokens,
|
|
221
|
+
cacheHitTokens,
|
|
222
|
+
cacheMissTokens,
|
|
223
|
+
hitRatio: parseFloat(hitRatio.toFixed(4)),
|
|
224
|
+
estimatedSavingsPct,
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Concurrency Gate & Workspace Scoping Manager.
|
|
3
|
+
*
|
|
4
|
+
* Implements:
|
|
5
|
+
* 1. Issue #93: Per-Parent Serialization Gate & Concurrency Cap (`tail gate`).
|
|
6
|
+
* - Guarantees serialized dispatch across simultaneous calls from the same parent.
|
|
7
|
+
* - Enforces maxConcurrentSubagents limit per parent session with atomic reservation.
|
|
8
|
+
* 2. Issue #87: Strict per-call `cwd` workspace directory scoping.
|
|
9
|
+
* - Prevents directory traversal outside root workspace (`path.resolve` containment check).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import path from 'path'
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_MAX_CONCURRENT_PER_PARENT = 3
|
|
15
|
+
|
|
16
|
+
export class SerializationGate {
|
|
17
|
+
constructor(maxConcurrent = DEFAULT_MAX_CONCURRENT_PER_PARENT) {
|
|
18
|
+
this.maxConcurrent = maxConcurrent
|
|
19
|
+
this.activePerParent = new Map() // parentId -> Set of active executionIds
|
|
20
|
+
this.tailChains = new Map() // parentId -> Promise chain
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Acquires a serialized execution slot for a parent session.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} parentId
|
|
27
|
+
* @param {string} executionId
|
|
28
|
+
* @returns {Promise<() => void>} Release function
|
|
29
|
+
*/
|
|
30
|
+
async acquire(parentId = 'root', executionId) {
|
|
31
|
+
const currentTail = this.tailChains.get(parentId) || Promise.resolve()
|
|
32
|
+
|
|
33
|
+
let releaseLock
|
|
34
|
+
const lockAcquired = new Promise((resolve) => {
|
|
35
|
+
releaseLock = resolve
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// Update tail chain so next request waits for this slot check to finish
|
|
39
|
+
this.tailChains.set(parentId, lockAcquired)
|
|
40
|
+
|
|
41
|
+
await currentTail
|
|
42
|
+
|
|
43
|
+
// Atomic capacity check
|
|
44
|
+
const active = this.activePerParent.get(parentId) || new Set()
|
|
45
|
+
if (active.size >= this.maxConcurrent) {
|
|
46
|
+
releaseLock()
|
|
47
|
+
throw new Error(
|
|
48
|
+
`[ConcurrencyGate] Parent session "${parentId}" reached maximum concurrent subagents limit (${this.maxConcurrent}).`
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
active.add(executionId)
|
|
53
|
+
this.activePerParent.set(parentId, active)
|
|
54
|
+
|
|
55
|
+
// Release serialization chain so next call can queue
|
|
56
|
+
releaseLock()
|
|
57
|
+
|
|
58
|
+
// Return the release function for the active worker slot
|
|
59
|
+
let released = false
|
|
60
|
+
return () => {
|
|
61
|
+
if (released) return
|
|
62
|
+
released = true
|
|
63
|
+
const current = this.activePerParent.get(parentId)
|
|
64
|
+
if (current) {
|
|
65
|
+
current.delete(executionId)
|
|
66
|
+
if (current.size === 0) {
|
|
67
|
+
this.activePerParent.delete(parentId)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
getActiveCount(parentId = 'root') {
|
|
74
|
+
return this.activePerParent.get(parentId)?.size || 0
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolves and validates a working directory (cwd) against a base workspace.
|
|
80
|
+
* Prevents escape via '../' or symlink traversal.
|
|
81
|
+
* Preserves normalized relative paths if relative cwd was requested.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} requestedCwd Relative or absolute path
|
|
84
|
+
* @param {string} [baseDir=process.cwd()] Root workspace boundary
|
|
85
|
+
* @returns {string} Safe relative or absolute path
|
|
86
|
+
*/
|
|
87
|
+
export function resolveScopedCwd(requestedCwd, baseDir = process.cwd()) {
|
|
88
|
+
if (!requestedCwd || typeof requestedCwd !== 'string' || requestedCwd.trim() === '') {
|
|
89
|
+
return baseDir
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const trimmed = requestedCwd.trim()
|
|
93
|
+
const resolvedBase = path.resolve(baseDir)
|
|
94
|
+
const candidate = path.isAbsolute(trimmed)
|
|
95
|
+
? path.resolve(trimmed)
|
|
96
|
+
: path.resolve(resolvedBase, trimmed)
|
|
97
|
+
|
|
98
|
+
// Containment check: candidate must start with resolvedBase
|
|
99
|
+
if (!candidate.startsWith(resolvedBase + path.sep) && candidate !== resolvedBase) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`[WorkspaceScoping] Access denied: requested cwd "${requestedCwd}" is outside workspace boundary "${resolvedBase}".`
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!path.isAbsolute(trimmed)) {
|
|
106
|
+
return path.relative(resolvedBase, candidate) || '.'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return candidate
|
|
110
|
+
}
|