Skip to content

Commit c8ed7c1

Browse files
authored
feat: add harness executor for coding agent CLI orchestration (#1978)
1 parent 493e192 commit c8ed7c1

39 files changed

Lines changed: 4483 additions & 126 deletions

README.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -287,27 +287,26 @@ For more examples, see the [Examples documentation](https://docs.dagu.sh/writing
287287
288288
## Built-in Executors
289289
290-
Dagu includes 17 built-in step executors. Each runs within the Dagu process (or worker) — no plugins or external runtimes required.
290+
Dagu includes built-in step executors. Each runs within the Dagu process (or worker) — no plugins or external runtimes required.
291291
292292
| Executor | Purpose |
293293
|----------|---------|
294294
| `command` | Shell commands and scripts (bash, sh, PowerShell, custom shells) |
295295
| `docker` | Run containers with registry auth, volume mounts, resource limits |
296296
| `kubernetes` | Execute Kubernetes Pods with resource requests, service accounts, namespaces |
297297
| `ssh` | Remote command execution with key-based auth and SFTP file transfer |
298+
| `harness` | Run coding agent CLIs (Claude Code, Codex, Copilot, OpenCode, Pi) as workflow steps |
299+
| `agent` | Multi-step LLM agent execution with tool calling |
300+
| `mail` | Send email via SMTP |
301+
| `template` | Text generation with template rendering |
298302
| `http` | HTTP requests (GET, POST, PUT, DELETE) with headers and authentication |
299303
| `sql` | Query PostgreSQL and SQLite with parameterized queries and result capture |
300304
| `redis` | Redis commands, pipelines, and Lua scripts |
301305
| `s3` | Upload, download, list, and delete S3 objects |
302306
| `jq` | JSON transformation using jq expressions |
303-
| `mail` | Send email via SMTP |
304307
| `archive` | Create zip/tar archives with glob patterns |
305308
| `dag` | Invoke another DAG as a sub-workflow with parameter passing |
306309
| `router` | Conditional step routing based on expressions |
307-
| `template` | Text generation with template rendering |
308-
| `chat` | LLM inference (OpenAI, Anthropic, Google Gemini, OpenRouter) |
309-
| `agentstep` | Multi-step LLM agent execution with tool calling |
310-
| `gha` | GitHub Actions execution |
311310

312311
See [step type documentation](https://docs.dagu.sh/step-types/shell) for configuration details of each executor.
313312

internal/agent/session.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,12 @@ func (sm *SessionManager) updateWorkingState(working bool) (string, string, floa
302302
sm.mu.Lock()
303303
defer sm.mu.Unlock()
304304

305+
// Ignore late working=true pulses from a loop that is already being
306+
// canceled. ensureLoop clears canceling before starting new work.
307+
if working && sm.canceling {
308+
return "", "", 0, false, false, nil, false, false
309+
}
310+
305311
if sm.working == working {
306312
if working {
307313
sm.promptsMu.Lock()

internal/agent/session_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,21 @@ func TestSessionManager_Cancel(t *testing.T) {
409409
err := sm.Cancel(context.Background())
410410
assert.NoError(t, err)
411411
})
412+
413+
t.Run("ignores late working pulse after cancel", func(t *testing.T) {
414+
t.Parallel()
415+
416+
sm := NewSessionManager(SessionManagerConfig{})
417+
sm.SetWorking(true)
418+
419+
err := sm.Cancel(context.Background())
420+
require.NoError(t, err)
421+
assert.False(t, sm.IsWorking())
422+
423+
// Simulate a late callback from the loop goroutine after cancellation.
424+
sm.SetWorking(true)
425+
assert.False(t, sm.IsWorking())
426+
})
412427
}
413428

414429
func TestSessionManager_GetMessages(t *testing.T) {

internal/cmd/helper.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,9 @@ func restoreDAGFromStatus(ctx context.Context, dag *core.DAG, status *exec.DAGRu
6969
// called after LoadDotEnv() so dotenv values are available during rebuild.
7070
//
7171
// The function preserves all JSON-serialized fields from the original DAG and
72-
// only copies JSON-excluded fields (Env, Params, ParamsJSON, SMTP, SSH,
73-
// RegistryAuths) from the rebuilt DAG.
72+
// only copies JSON-excluded fields (Env, Params, ParamsJSON, SMTP, SSH, S3,
73+
// Redis, Harness, Harnesses, Kubernetes, RegistryAuths, WorkingDirExplicit)
74+
// from the rebuilt DAG.
7475
func rebuildDAGFromYAML(ctx context.Context, dag *core.DAG) (*core.DAG, error) {
7576
if len(dag.YamlData) == 0 {
7677
return dag, nil
@@ -122,7 +123,13 @@ func rebuildDAGFromYAML(ctx context.Context, dag *core.DAG) (*core.DAG, error) {
122123
dag.ParamsJSON = fresh.ParamsJSON
123124
dag.SMTP = fresh.SMTP
124125
dag.SSH = fresh.SSH
126+
dag.S3 = fresh.S3
127+
dag.Redis = fresh.Redis
128+
dag.Harness = fresh.Harness
129+
dag.Harnesses = fresh.Harnesses
130+
dag.Kubernetes = fresh.Kubernetes
125131
dag.RegistryAuths = fresh.RegistryAuths
132+
dag.WorkingDirExplicit = fresh.WorkingDirExplicit
126133

127134
core.InitializeDefaults(dag)
128135

internal/cmd/helper_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,3 +287,30 @@ registry_auths:
287287
require.Equal(t, "${REGISTRY_USER}", result.RegistryAuths["registry.example.com"].Username)
288288
require.Equal(t, "${REGISTRY_PASSWORD}", result.RegistryAuths["registry.example.com"].Password)
289289
}
290+
291+
func TestRestoreDAGFromStatus_RestoresHarnessConfigFromBaseConfig(t *testing.T) {
292+
dag := &core.DAG{
293+
Name: "test-dag",
294+
YamlData: []byte(`
295+
steps:
296+
- command: Review the repository
297+
`),
298+
BaseConfigData: []byte(`
299+
harnesses:
300+
passthrough:
301+
binary: cat
302+
prompt_mode: stdin
303+
harness:
304+
provider: passthrough
305+
`),
306+
}
307+
status := &exec.DAGRunStatus{}
308+
309+
result, err := restoreDAGFromStatus(context.Background(), dag, status)
310+
require.NoError(t, err)
311+
require.NotNil(t, result.Harness)
312+
assert.Equal(t, "passthrough", result.Harness.Config["provider"])
313+
require.NotNil(t, result.Harnesses)
314+
require.Contains(t, result.Harnesses, "passthrough")
315+
assert.Equal(t, "cat", result.Harnesses["passthrough"].Binary)
316+
}

internal/cmn/schema/dag.schema.json

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,14 @@
721721
"$ref": "#/definitions/llmConfig",
722722
"description": "Default LLM configuration for all chat steps in the DAG. Steps with type: chat inherit this configuration if they don't specify their own llm field. If a step specifies llm, it completely overrides the DAG-level configuration."
723723
},
724+
"harness": {
725+
"$ref": "#/definitions/harnessDefaultsConfig",
726+
"description": "Default harness configuration for coding-agent steps in the DAG. Steps with type: harness inherit these defaults, and steps without an explicit type are treated as harness steps when this block is present. Step-level config overrides primary keys and replaces DAG-level fallback when fallback is set."
727+
},
728+
"harnesses": {
729+
"$ref": "#/definitions/harnessDefinitions",
730+
"description": "Reusable custom harness definitions available to harness steps. Each definition describes how to invoke a named harness CLI; steps reference definitions via config.provider."
731+
},
724732
"steps": {
725733
"oneOf": [
726734
{
@@ -1727,6 +1735,14 @@
17271735
}
17281736
}
17291737
},
1738+
{
1739+
"if": { "properties": { "type": { "const": "harness" } } },
1740+
"then": {
1741+
"properties": {
1742+
"config": { "$ref": "#/definitions/harnessExecutorConfig" }
1743+
}
1744+
}
1745+
},
17301746
{
17311747
"if": {
17321748
"properties": { "type": { "const": "router" } },
@@ -3260,6 +3276,159 @@
32603276
],
32613277
"description": "Redis executor configuration for executing Redis commands, managing cache, pub/sub, streams, and distributed locks."
32623278
},
3279+
"harnessConfigScalar": {
3280+
"oneOf": [
3281+
{ "type": "string" },
3282+
{ "type": "number" },
3283+
{ "type": "boolean" }
3284+
],
3285+
"description": "Scalar harness flag value."
3286+
},
3287+
"harnessConfigValue": {
3288+
"oneOf": [
3289+
{
3290+
"$ref": "#/definitions/harnessConfigScalar"
3291+
},
3292+
{
3293+
"type": "array",
3294+
"items": {
3295+
"$ref": "#/definitions/harnessConfigScalar"
3296+
}
3297+
}
3298+
],
3299+
"description": "Harness flag value. Scalars become a single CLI flag value; arrays become repeated flags."
3300+
},
3301+
"harnessNamedDefinition": {
3302+
"type": "object",
3303+
"additionalProperties": false,
3304+
"required": ["binary"],
3305+
"allOf": [
3306+
{
3307+
"if": {
3308+
"properties": {
3309+
"prompt_mode": { "const": "flag" }
3310+
},
3311+
"required": ["prompt_mode"]
3312+
},
3313+
"then": {
3314+
"required": ["prompt_flag"]
3315+
}
3316+
},
3317+
{
3318+
"if": {
3319+
"required": ["prompt_flag"]
3320+
},
3321+
"then": {
3322+
"properties": {
3323+
"prompt_mode": { "const": "flag" }
3324+
},
3325+
"required": ["prompt_mode"]
3326+
}
3327+
}
3328+
],
3329+
"properties": {
3330+
"binary": {
3331+
"type": "string",
3332+
"description": "CLI binary name or path for the custom harness."
3333+
},
3334+
"prefix_args": {
3335+
"type": "array",
3336+
"items": {
3337+
"type": "string"
3338+
},
3339+
"description": "Arguments that always appear before prompt placement and runtime flags."
3340+
},
3341+
"prompt_mode": {
3342+
"type": "string",
3343+
"enum": ["arg", "flag", "stdin"],
3344+
"description": "How the prompt is passed to the CLI."
3345+
},
3346+
"prompt_flag": {
3347+
"type": "string",
3348+
"description": "Flag token used when prompt_mode is flag."
3349+
},
3350+
"prompt_position": {
3351+
"type": "string",
3352+
"enum": ["before_flags", "after_flags"],
3353+
"description": "Whether prompt tokens appear before or after runtime flags."
3354+
},
3355+
"flag_style": {
3356+
"type": "string",
3357+
"enum": ["gnu_long", "single_dash"],
3358+
"description": "Default flag prefix style for runtime options that do not have an explicit option_flags override."
3359+
},
3360+
"option_flags": {
3361+
"type": "object",
3362+
"additionalProperties": {
3363+
"type": "string"
3364+
},
3365+
"description": "Per-option override mapping from config key to exact CLI flag token."
3366+
}
3367+
}
3368+
},
3369+
"harnessDefinitions": {
3370+
"type": "object",
3371+
"additionalProperties": {
3372+
"oneOf": [
3373+
{ "$ref": "#/definitions/harnessNamedDefinition" },
3374+
{ "type": "null" }
3375+
]
3376+
},
3377+
"description": "Reusable named custom harness definitions. Null entries remove inherited definitions from base config."
3378+
},
3379+
"harnessProviderConfig": {
3380+
"type": "object",
3381+
"propertyNames": {
3382+
"not": {
3383+
"enum": ["binary", "prompt_args", "fallback"]
3384+
}
3385+
},
3386+
"properties": {
3387+
"provider": {
3388+
"type": "string",
3389+
"description": "Harness provider name. May reference a built-in provider such as claude, codex, copilot, opencode, or pi, or a custom top-level harnesses entry. Supports ${VAR} interpolation."
3390+
}
3391+
},
3392+
"additionalProperties": {
3393+
"$ref": "#/definitions/harnessConfigValue"
3394+
},
3395+
"required": ["provider"],
3396+
"description": "Primary or fallback harness provider configuration. Reserved keys are provider and fallback; all other keys are passed through to the CLI as flags."
3397+
},
3398+
"harnessExecutorConfig": {
3399+
"type": "object",
3400+
"propertyNames": {
3401+
"not": {
3402+
"enum": ["binary", "prompt_args"]
3403+
}
3404+
},
3405+
"properties": {
3406+
"provider": {
3407+
"type": "string",
3408+
"description": "Harness provider name. May reference a built-in provider such as claude, codex, copilot, opencode, or pi, or a custom top-level harnesses entry. Supports ${VAR} interpolation."
3409+
},
3410+
"fallback": {
3411+
"type": "array",
3412+
"items": {
3413+
"$ref": "#/definitions/harnessProviderConfig"
3414+
},
3415+
"description": "Ordered fallback harness provider configs tried after the primary config fails. The fallback key is reserved and is not passed through as a CLI flag."
3416+
}
3417+
},
3418+
"additionalProperties": {
3419+
"$ref": "#/definitions/harnessConfigValue"
3420+
},
3421+
"description": "Harness step configuration. Unknown keys are passed through to the CLI as flags. When no DAG-level harness defaults exist, specify provider."
3422+
},
3423+
"harnessDefaultsConfig": {
3424+
"allOf": [
3425+
{
3426+
"$ref": "#/definitions/harnessExecutorConfig"
3427+
},
3428+
{ "required": ["provider"] }
3429+
],
3430+
"description": "DAG-level harness defaults. Specify provider plus optional CLI flags and fallback providers."
3431+
},
32633432
"kubernetesKeySelector": {
32643433
"type": "object",
32653434
"additionalProperties": false,
@@ -4707,6 +4876,7 @@
47074876
"postgres",
47084877
"sqlite",
47094878
"redis",
4879+
"harness",
47104880
"router",
47114881
"agent"
47124882
],
@@ -4833,6 +5003,14 @@
48335003
}
48345004
}
48355005
},
5006+
{
5007+
"if": { "properties": { "type": { "const": "harness" } } },
5008+
"then": {
5009+
"properties": {
5010+
"config": { "$ref": "#/definitions/harnessExecutorConfig" }
5011+
}
5012+
}
5013+
},
48365014
{
48375015
"if": { "properties": { "type": { "enum": ["command", "shell"] } } },
48385016
"then": {

0 commit comments

Comments
 (0)