Skip to content

Commit 77402b2

Browse files
nhortonclaude
andauthored
feat: Add concurrent steps support in workflow definitions (#191)
Enable workflows to specify nested arrays of step IDs to indicate steps that can be executed in parallel. The meta template renders concurrent step groups as "Concurrent Steps" with numbered "Background Task" items. Changes: - Schema: Added STEP_ID_SCHEMA, CONCURRENT_STEPS_SCHEMA, WORKFLOW_STEP_ENTRY_SCHEMA - Workflow steps accept either string (sequential) or array (concurrent) - Single-item arrays indicate steps with multiple parallel instances - Parser: Added WorkflowStepEntry dataclass for sequential/concurrent groups - Updated Workflow to use step_entries with backward-compatible steps property - Added get_step_entry_position_in_workflow() and get_concurrent_step_info() - Generator: Build step_entries with concurrency info in workflow context - Template: Render concurrent steps with "Background Task N" formatting - Tests: Added test fixture and 9 new unit tests - Docs: Updated architecture.md and CHANGELOG.md https://claude.ai/code/session_011qMp5U4AFQLMbJQvCE8Ck4 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8ad17fe commit 77402b2

15 files changed

Lines changed: 525 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- Concurrent steps support in workflow definitions
12+
- Workflows can now specify nested arrays of step IDs to indicate steps that can run in parallel
13+
- Example: `steps: [setup, [task_a, task_b, task_c], finalize]` runs task_a/b/c concurrently
14+
- Single-item arrays indicate a step with multiple parallel instances (e.g., `[fetch_campaign_data]` runs for each campaign)
15+
- New `WorkflowStepEntry` dataclass in parser for sequential/concurrent step groups
16+
- Meta-skill template renders concurrent steps as "Background Task 1/2/3" with clear instructions
17+
- Added `get_step_entry_position_in_workflow()` and `get_concurrent_step_info()` methods to JobDefinition
18+
- Full backward compatibility: existing workflows with simple step arrays continue to work
1119
- Agent delegation field for job.yml steps
1220
- New `agent` field on steps allows specifying an agent type (e.g., `agent: general-purpose`)
1321
- When `agent` is set, generated Claude Code skills automatically include `context: fork` and `agent:` in frontmatter

doc/architecture.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,13 +365,22 @@ changelog:
365365
366366
# Workflows define named sequences of steps that form complete processes.
367367
# Steps not in any workflow are "standalone skills" that can be run anytime.
368+
# Steps can be listed as simple strings (sequential) or arrays (concurrent execution).
369+
#
370+
# Concurrent step patterns:
371+
# 1. Multiple different steps: [step_a, step_b] - run both in parallel
372+
# 2. Single step with multiple instances: [fetch_campaign_data] - indicates this
373+
# step should be run in parallel for each instance (e.g., each ad campaign)
374+
#
375+
# Use a single-item array when a step needs multiple parallel instances, like
376+
# "fetch performance data" that runs once per campaign in an ad reporting job.
368377
workflows:
369378
- name: full_analysis
370379
summary: "Complete competitive analysis from identification through positioning"
371380
steps:
372381
- identify_competitors
373-
- primary_research
374-
- secondary_research
382+
# Steps in an array execute concurrently (as "Background Tasks")
383+
- [primary_research, secondary_research]
375384
- comparative_report
376385
- positioning
377386

src/deepwork/core/generator.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,14 +364,38 @@ def _build_meta_skill_context(
364364

365365
steps_info.append(step_info)
366366

367-
# Build workflow info
367+
# Build workflow info with concurrent step support
368368
workflows_info = []
369369
for workflow in job.workflows:
370+
# Build step entries with concurrency info
371+
step_entries_info = []
372+
for entry in workflow.step_entries:
373+
entry_info: dict[str, Any] = {
374+
"is_concurrent": entry.is_concurrent,
375+
"step_ids": entry.step_ids,
376+
}
377+
if entry.is_concurrent:
378+
# Add detailed step info for each concurrent step
379+
concurrent_steps = []
380+
for i, step_id in enumerate(entry.step_ids):
381+
step = job.get_step(step_id)
382+
concurrent_steps.append(
383+
{
384+
"id": step_id,
385+
"name": step.name if step else step_id,
386+
"description": step.description if step else "",
387+
"task_number": i + 1,
388+
}
389+
)
390+
entry_info["concurrent_steps"] = concurrent_steps
391+
step_entries_info.append(entry_info)
392+
370393
workflows_info.append(
371394
{
372395
"name": workflow.name,
373396
"summary": workflow.summary,
374-
"steps": workflow.steps,
397+
"steps": workflow.steps, # Flattened for backward compat
398+
"step_entries": step_entries_info, # New: with concurrency info
375399
"first_step": workflow.steps[0] if workflow.steps else None,
376400
}
377401
)

src/deepwork/core/parser.py

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,21 +187,74 @@ def from_dict(cls, data: dict[str, Any]) -> "Step":
187187
)
188188

189189

190+
@dataclass
191+
class WorkflowStepEntry:
192+
"""Represents a single entry in a workflow's step list.
193+
194+
Each entry can be either:
195+
- A single step (sequential execution)
196+
- A list of steps (concurrent execution)
197+
"""
198+
199+
step_ids: list[str] # Single step has one ID, concurrent group has multiple
200+
is_concurrent: bool = False
201+
202+
@property
203+
def first_step(self) -> str:
204+
"""Get the first step ID in this entry."""
205+
return self.step_ids[0] if self.step_ids else ""
206+
207+
def all_step_ids(self) -> list[str]:
208+
"""Get all step IDs in this entry."""
209+
return self.step_ids
210+
211+
@classmethod
212+
def from_data(cls, data: str | list[str]) -> "WorkflowStepEntry":
213+
"""Create WorkflowStepEntry from YAML data (string or list)."""
214+
if isinstance(data, str):
215+
return cls(step_ids=[data], is_concurrent=False)
216+
else:
217+
return cls(step_ids=list(data), is_concurrent=True)
218+
219+
190220
@dataclass
191221
class Workflow:
192222
"""Represents a named workflow grouping steps into a multi-step sequence."""
193223

194224
name: str
195225
summary: str
196-
steps: list[str] # List of step IDs in order
226+
step_entries: list[WorkflowStepEntry] # List of step entries (sequential or concurrent)
227+
228+
@property
229+
def steps(self) -> list[str]:
230+
"""Get flattened list of all step IDs for backward compatibility."""
231+
result: list[str] = []
232+
for entry in self.step_entries:
233+
result.extend(entry.step_ids)
234+
return result
235+
236+
def get_step_entry_for_step(self, step_id: str) -> WorkflowStepEntry | None:
237+
"""Get the workflow step entry containing the given step ID."""
238+
for entry in self.step_entries:
239+
if step_id in entry.step_ids:
240+
return entry
241+
return None
242+
243+
def get_entry_index_for_step(self, step_id: str) -> int | None:
244+
"""Get the index of the entry containing the given step ID."""
245+
for i, entry in enumerate(self.step_entries):
246+
if step_id in entry.step_ids:
247+
return i
248+
return None
197249

198250
@classmethod
199251
def from_dict(cls, data: dict[str, Any]) -> "Workflow":
200252
"""Create Workflow from dictionary."""
253+
step_entries = [WorkflowStepEntry.from_data(s) for s in data["steps"]]
201254
return cls(
202255
name=data["name"],
203256
summary=data["summary"],
204-
steps=data["steps"],
257+
step_entries=step_entries,
205258
)
206259

207260

@@ -407,6 +460,57 @@ def get_step_position_in_workflow(self, step_id: str) -> tuple[int, int] | None:
407460
except ValueError:
408461
return None
409462

463+
def get_step_entry_position_in_workflow(
464+
self, step_id: str
465+
) -> tuple[int, int, WorkflowStepEntry] | None:
466+
"""
467+
Get the entry-based position of a step within its workflow.
468+
469+
For concurrent step groups, multiple steps share the same entry position.
470+
471+
Args:
472+
step_id: Step ID to look up
473+
474+
Returns:
475+
Tuple of (1-based entry position, total entries, WorkflowStepEntry),
476+
or None if standalone
477+
"""
478+
workflow = self.get_workflow_for_step(step_id)
479+
if not workflow:
480+
return None
481+
482+
entry_index = workflow.get_entry_index_for_step(step_id)
483+
if entry_index is None:
484+
return None
485+
486+
entry = workflow.step_entries[entry_index]
487+
return (entry_index + 1, len(workflow.step_entries), entry)
488+
489+
def get_concurrent_step_info(self, step_id: str) -> tuple[int, int] | None:
490+
"""
491+
Get information about a step's position within a concurrent group.
492+
493+
Args:
494+
step_id: Step ID to look up
495+
496+
Returns:
497+
Tuple of (1-based position in group, total in group) if step is in
498+
a concurrent group, None if step is not in a concurrent group
499+
"""
500+
workflow = self.get_workflow_for_step(step_id)
501+
if not workflow:
502+
return None
503+
504+
entry = workflow.get_step_entry_for_step(step_id)
505+
if entry is None or not entry.is_concurrent:
506+
return None
507+
508+
try:
509+
index = entry.step_ids.index(step_id)
510+
return (index + 1, len(entry.step_ids))
511+
except ValueError:
512+
return None
513+
410514
def validate_workflows(self) -> None:
411515
"""
412516
Validate workflow definitions.

src/deepwork/schemas/job_schema.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,30 @@
4646
],
4747
}
4848

49+
# Schema for a single step reference (step ID)
50+
STEP_ID_SCHEMA: dict[str, Any] = {
51+
"type": "string",
52+
"pattern": "^[a-z][a-z0-9_]*$",
53+
}
54+
55+
# Schema for a concurrent step group (array of step IDs that can run in parallel)
56+
# minItems=1 allows single-item arrays to indicate a step with multiple parallel instances
57+
# (e.g., [fetch_campaign_data] means run this step for each campaign in parallel)
58+
CONCURRENT_STEPS_SCHEMA: dict[str, Any] = {
59+
"type": "array",
60+
"minItems": 1,
61+
"description": "Array of step IDs that can be executed concurrently, or single step with multiple instances",
62+
"items": STEP_ID_SCHEMA,
63+
}
64+
65+
# Schema for a workflow step entry (either single step or concurrent group)
66+
WORKFLOW_STEP_ENTRY_SCHEMA: dict[str, Any] = {
67+
"oneOf": [
68+
STEP_ID_SCHEMA,
69+
CONCURRENT_STEPS_SCHEMA,
70+
],
71+
}
72+
4973
# Schema for a workflow definition
5074
WORKFLOW_SCHEMA: dict[str, Any] = {
5175
"type": "object",
@@ -65,11 +89,8 @@
6589
"steps": {
6690
"type": "array",
6791
"minItems": 1,
68-
"description": "Ordered list of step IDs that comprise this workflow",
69-
"items": {
70-
"type": "string",
71-
"pattern": "^[a-z][a-z0-9_]*$",
72-
},
92+
"description": "Ordered list of step entries. Each entry is either a step ID (string) or an array of step IDs for concurrent execution.",
93+
"items": WORKFLOW_STEP_ENTRY_SCHEMA,
7394
},
7495
},
7596
"additionalProperties": False,

src/deepwork/templates/claude/skill-job-meta.md.jinja

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,17 @@ description: "{{ job_summary }}"
4747
{{ workflow.summary }}
4848

4949
**Steps in order**:
50-
{% for step_id in workflow.steps %}
50+
{% for entry in workflow.step_entries %}
51+
{% if entry.is_concurrent %}
52+
{{ loop.index }}. **Concurrent Steps** - Execute the following tasks in parallel:
53+
{% for task in entry.concurrent_steps %}
54+
- **Background Task {{ task.task_number }}**: {{ task.id }} - {{ task.description }}
55+
{% endfor %}
56+
{% else %}
57+
{% set step_id = entry.step_ids[0] %}
5158
{% set step = steps | selectattr("id", "equalto", step_id) | first %}
5259
{{ loop.index }}. **{{ step_id }}** - {{ step.description if step else "Unknown step" }}
60+
{% endif %}
5361
{% endfor %}
5462

5563
**Start workflow**: `/{{ job_name }}.{{ workflow.first_step }}`
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
name: concurrent_workflow
2+
version: "1.0.0"
3+
summary: "Workflow with concurrent steps for testing"
4+
description: |
5+
A test workflow that demonstrates concurrent step execution.
6+
Some steps run sequentially while others run in parallel.
7+
8+
workflows:
9+
- name: full_analysis
10+
summary: "Complete analysis workflow with parallel research phase"
11+
steps:
12+
- setup
13+
- [research_web, research_docs, research_interviews]
14+
- compile_results
15+
- final_review
16+
17+
steps:
18+
- id: setup
19+
name: "Setup"
20+
description: "Initialize the analysis environment"
21+
instructions_file: steps/setup.md
22+
outputs:
23+
- setup_complete.md
24+
25+
- id: research_web
26+
name: "Web Research"
27+
description: "Research information from web sources"
28+
instructions_file: steps/research_web.md
29+
inputs:
30+
- file: setup_complete.md
31+
from_step: setup
32+
outputs:
33+
- web_research.md
34+
dependencies:
35+
- setup
36+
37+
- id: research_docs
38+
name: "Document Research"
39+
description: "Research information from internal documents"
40+
instructions_file: steps/research_docs.md
41+
inputs:
42+
- file: setup_complete.md
43+
from_step: setup
44+
outputs:
45+
- docs_research.md
46+
dependencies:
47+
- setup
48+
49+
- id: research_interviews
50+
name: "Interview Research"
51+
description: "Research information from stakeholder interviews"
52+
instructions_file: steps/research_interviews.md
53+
inputs:
54+
- file: setup_complete.md
55+
from_step: setup
56+
outputs:
57+
- interviews_research.md
58+
dependencies:
59+
- setup
60+
61+
- id: compile_results
62+
name: "Compile Results"
63+
description: "Compile all research into a unified report"
64+
instructions_file: steps/compile_results.md
65+
inputs:
66+
- file: web_research.md
67+
from_step: research_web
68+
- file: docs_research.md
69+
from_step: research_docs
70+
- file: interviews_research.md
71+
from_step: research_interviews
72+
outputs:
73+
- compiled_results.md
74+
dependencies:
75+
- research_web
76+
- research_docs
77+
- research_interviews
78+
79+
- id: final_review
80+
name: "Final Review"
81+
description: "Review and finalize the analysis"
82+
instructions_file: steps/final_review.md
83+
inputs:
84+
- file: compiled_results.md
85+
from_step: compile_results
86+
outputs:
87+
- final_report.md
88+
dependencies:
89+
- compile_results
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Compile Results Instructions
2+
3+
Compile all research into a unified report:
4+
5+
1. Merge findings from all research sources
6+
2. Identify patterns and insights
7+
3. Create unified document
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Final Review Instructions
2+
3+
Review and finalize the analysis:
4+
5+
1. Proofread and edit
6+
2. Verify accuracy
7+
3. Format final report
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Document Research Instructions
2+
3+
Research information from internal documents:
4+
5+
1. Review internal documentation
6+
2. Extract relevant data
7+
3. Summarize findings

0 commit comments

Comments
 (0)