Vancetope — Workflows
A Workflow is a project-scoped automation: a state-machine plan of typed tasks (Agent / Shell / Script / Tool / Gate / Timer / Condition / Sub-Workflow / Terminal) that is started by external triggers and runs via append-only Journal Records. Workflows live as YAML documents under
_vance/workflows/<name>.yamlin the Document Layer.Workflows are not a replacement for session-driven Chat Engines (Arthur) and not a multi-phase plan runner for Sessions (Vogon). They are the layer above the Engines: they spawn Jeltz/Ford/Marvin/Vogon as sub-tasks, evaluate conditions on structured outputs, and survive Session boundaries — some Workflows run for weeks.
Each Workflow definition is declarative configuration. At each start, the Engine freezes the complete YAML as a snapshot into the Run — edits to the source document do not affect running Runs.
1. Terminology
| Term | Definition |
|---|---|
| Workflow-Doc | YAML document under _vance/workflows/<name>.yaml in the Project or in the _tenant Tenant Scope. The filename (without .yaml) is the Workflow name. |
| Workflow-Run | A running instance, identified by a workflowRunId (full 32-hex UUID, dash-stripped — unique across all Scopes). Persisted as an append-only Journal in the Mongo collection magrathea_journal. |
| State | Entry under states: in the YAML. Carries a Task type, an optional storeAs: variable, transitions (on:/catch:), and potentially a retry block. |
| Task | An execution instance of a State. Persisted in the Mongo collection magrathea_tasks while it is pending/claimed/running. |
| Outcome | String that a Task produces at the end (success, failure, or type-specific like approved, business_error, timeout, fired). Matched by the Transition Resolver against on:/catch:. |
| Journal | Append-only audit trail of typed records (StartRecord, StateEnteredRecord, TaskStartedRecord, TaskResultRecord, VarRecord, StatusRecord, ResultRecord, …). Source of truth for each Run; all status views are projections. |
| Frozen Snapshot | The complete YAML body saved into the StartRecord at start(). Running Runs read exclusively from this. |
Workflow ↔ Recipe ↔ Engine: Engines are code (Arthur/Ford/Vogon/Marvin/Jeltz). Recipes are named Engine configurations. Workflows orchestrate Tasks — some of these Tasks spawn Recipes. A Workflow State definition references a Recipe by name; inline Recipe definitions are not allowed.
2. Workflow YAML Schema
# _vance/workflows/pr-review.yaml
description: |
Pull-Request-Review-Flow: Plan, Tests, Review-Gate, Merge.
version: "1"
start: plan
parameters:
pr_url: { type: string, required: true }
reviewer: { type: string, required: false, default: "@maintainers" }
bounds:
maxTotalCostUsd: 10.0
maxWallclockSeconds: 604800 # 7 days
maxTaskSpawns: 100
allowedTools:
- github.merge_pr
- github.add_comment
- web_search
tags: [pr, review]
states:
plan:
type: agent_task
recipe: jeltz
params:
prompt: "Analyse PR ${params.pr_url}, identify risks, propose review plan."
schema:
risk: { type: string, enum: [low, medium, high] }
focus_areas: { type: array, items: { type: string } }
storeAs: plan_output
timeoutSeconds: 600
on:
success: run_checks
catch:
agent_error: human_review
run_checks:
type: shell_task
run: "npm test && npm run lint"
dirName: workspace
timeoutSeconds: 1800
retry:
maxAttempts: 2
on: [technical_error, timeout]
backoffSeconds: 60
on:
success: route
catch:
business_error: debug
technical_error: escalate
route:
type: condition_task
transitions:
- if: "#state['plan_output']['risk'] == 'low'"
to: merge
- else: human_review
human_review:
type: gate_task
inbox:
kind: APPROVAL
title: "PR ${params.pr_url} approve?"
assignedTo: "${params.reviewer}"
criticality: NORMAL
# tags: [pr-review] # optional
timeoutSeconds: 604800
storeAs: review_decision
on:
approved: merge
rejected: plan
catch:
timeout: escalate
merge:
type: tool_task
tool: github.merge_pr
params:
url: "${params.pr_url}"
on:
success: done
catch:
permission_error: escalate
technical_error: retry_or_abort
debug:
type: agent_task
recipe: ford
params:
prompt: |
Diagnose the failing checks and propose a fix.
stderr: ${state.check_output.stderr}
on:
success: run_checks
failure: escalate
retry_or_abort:
type: gate_task
inbox:
kind: DECISION
title: "Merge tool kept failing — retry or abort?"
options: [retry, abort]
on:
retry: merge
abort: escalate
done:
type: terminal
outcome: success
result:
summary: "${state.review_summary}"
escalate:
type: terminal
outcome: failure
2.1 Top-Level Fields
| Field | Required | Meaning |
|---|---|---|
description |
optional | Audit marker, visible in Web UI listings. |
version |
optional | Free string, lands in StartRecord.workflowVersion. |
start |
yes | Name of the initial State. Must exist in states:. |
parameters |
optional | Map of caller parameter schemas. Permissive pass-through: caller parameters outside the schema are forwarded. |
bounds |
optional | Global guardrails per Run (§9). |
allowedTools |
optional | Workflow-specific Tool whitelist; AND-combined with Project + Tenant pools (§10). |
tags |
optional | Audit labels. |
states |
yes | Map state-name → state-spec. At least one State, must contain start. |
2.2 Common State Fields
| Field | Meaning |
|---|---|
type |
Required. One of agent_task / shell_task / script_task / tool_task / gate_task / timer_task / condition_task / workflow_task / terminal. |
description |
Audit string. |
timeoutSeconds |
Task-level timeout. shell_task/script_task block the Lane until waitMs; gate_task creates a parallel timeout timer (§5.4). |
storeAs |
Variable key. For non-null output, the Dispatcher writes a VarRecord(key, value) to the Journal. |
on: |
Map outcome → next-state. Checked first by the Resolver. |
catch: |
Map error-kind → next-state. Outcome interpreted as ErrorKind enum (case-insensitive, dashes→underscores). |
retry: |
Spec with maxAttempts, on: [error-kinds], backoffSeconds. State-level Retry preempts Resolver routing (§4.3). |
2.3 YAML 1.2 Boolean Semantics
The Workflow parser uses a custom resolver with YAML 1.2 boolean rules: only true/false are coerced. The barewords on:/off:/yes:/no: remain strings — otherwise, SnakeYAML defaults (YAML 1.1) would rewrite the on: transition block keyword to Boolean.TRUE.
2.4 Dataflow — ${params.X} / ${state.X}
Any string value in a State spec may contain placeholders that are resolved immediately before Task execution (centrally in the Dispatcher across the entire spec map, before the Type Executor sees them):
${params.<key>}— a Run parameter (fromparameters:).${state.<key>}— a variable that an earlier State wrote viastoreAs: <key>. Nested access works:${state.review.summary}.
This is how data flows from Task to Task: a Task writes its output with storeAs, a later Task reads it via ${state.<key>} into a Prompt, Tool parameter, Gate title/body, or Terminal result. A missing key resolves to an empty string (no crash). Values without ${…} remain untouched.
This is different from SpEL in condition_task.transitions[].if (#state['k'] / #params['k'], §3.6) — SpEL evaluates conditions, ${…} substitutes text.
3. Task Types
Nine types, all sharing the same lifecycle model: each Task produces a TaskCompletedEvent with an outcome string that triggers the Transition Resolver. Sync vs. Async types differ only in who writes the TaskResultRecord.
| Type | Synchronous? | What it does |
|---|---|---|
condition_task |
yes | SpEL branch match over transitions: list. Sets nextStateOverride. |
tool_task |
yes | Calls tool: via Tool Dispatcher. Output = Tool Result Map. |
shell_task |
yes (blocks Lane until timeoutSeconds) |
Shell command via ExecManager. Output = {status, exitCode, stdout, stderr, durationMs}. |
script_task |
yes (blocks Lane until timeoutSeconds) |
JS script via the Unified Script Executor (ScriptActionExecutor). Output = Script return value. |
terminal |
yes | Writes StatusRecord(DONE/FAILED) + optional ResultRecord. No transitions. |
agent_task |
no | Spawns ThinkProcess via Recipe. Completion via ThinkProcess Termination Event. |
gate_task |
no | Creates Inbox Item. Completion via Inbox Answer Event. |
timer_task |
no | Schedules entry in magrathea_timers. Completion via Timer Scanner. |
workflow_task |
no | Spawns Sub-Workflow. Completion via Sub-Run Terminal Event. |
3.1 agent_task
plan:
type: agent_task
recipe: jeltz
params:
prompt: "Analyse the PR ..."
schema: { ... }
storeAs: plan_output
timeoutSeconds: 600
on:
success: run_checks
catch:
agent_error: human_review
recipe: is required and resolved via the normal Recipe cascade. params: lands as engineParams on the spawned ThinkProcess. Jeltz consumes prompt+schema directly; Ford/Vogon/Marvin read Recipe-specific fields.
Outcome mapping:
- Jeltz: Wrapper parse from the last Assistant message.
success: true→outcome=success,output=data.success: false→outcome=agent_error,output=lastInvalid. - Other Engines: Terminal CloseReason to Outcome.
DONE/AUTO_CLOSE→successwith the last Assistant message as output.STALE→technical_error.STOPPED/ARCHIVED/USER_DELETE/ABANDONED→cancelled.
Sub-ThinkProcesses run in a dedicated system session _magrathea_<runId> analogous to the Scheduler pattern.
3.2 shell_task & script_task
Two separate Task types — historically, script_task was shell-only; JS execution is now its own type, and the Shell Executor is now called shell_task (rename in MagratheaTaskType, symmetrical to trigger-actions.md §4).
shell_task — Shell command via ExecManager:
run_checks:
type: shell_task
run: "npm test && npm run lint"
dirName: workspace
timeoutSeconds: 1800
on:
success: review
catch:
business_error: debug
technical_error: escalate
Delegates to the existing ExecManager.submitTrackedAndRender. dirName: references a Workspace RootDir (see workspace.md); without it, the submit fails with technical_error. Output = {status, exitCode, stdout, stderr, durationMs}.
Outcome mapping:
| ExecJob Status | exitCode | Workflow Outcome |
|---|---|---|
COMPLETED |
0 |
success |
COMPLETED |
≠0 |
business_error |
KILLED |
— | timeout (Watchdog kill) |
RUNNING |
— | timeout (waitMs exhausted) |
FAILED / ORPHANED |
— | technical_error |
script_task — JS script via the Unified Script Executor (ScriptActionExecutor, the same path as Script Trigger Actions — see trigger-actions.md §4.4):
transform:
type: script_task
source: document # document | workspace
path: _vance/scripts/transform.js
dirName: workspace # optional Workspace RootDir
timeoutSeconds: 120
on:
success: next
catch:
technical_error: escalate
The Script return value is the Task output (readable via storeAs). Outcomes: success / technical_error.
3.3 tool_task
merge:
type: tool_task
tool: github.merge_pr
params:
url: "${params.pr_url}"
storeAs: merge_result
on:
success: done
catch:
permission_error: escalate
technical_error: retry_or_abort
Direct call via ToolDispatcher. The Tool Invocation Context carries tenantId/projectId/userId=startedBy, but sessionId/processId are null — Tools that need these must validate defensively.
Outcome: success, permission_error (PermissionDeniedException), technical_error (ToolException or others).
3.4 gate_task
Pause on a User Inbox Item. Three variants:
review:
type: gate_task
inbox:
kind: APPROVAL # APPROVAL | DECISION | FEEDBACK
title: "Approve?"
body: "${state.summary}"
assignedTo: "@maintainers" # or a User name or Tag
criticality: NORMAL # LOW | NORMAL | CRITICAL
tags: [pr-review]
options: [approve, reject] # only for DECISION
timeoutSeconds: 604800 # optional: parallel timeout timer
storeAs: review_decision
on:
approved: merge
rejected: plan
catch:
timeout: escalate
assignedTo fallback: Spec value → startedBy → "@system".
Outcome mapping by Answer Outcome × InboxItemType:
| Type \ Answer | DECIDED | INSUFFICIENT_INFO | UNDECIDABLE |
|---|---|---|---|
| APPROVAL | approved/rejected (from value.approved bool) |
insufficient_info |
undecidable |
| DECISION | <chosen option> (from value.chosen string) |
insufficient_info |
undecidable |
| FEEDBACK | success (text in value.text) |
insufficient_info |
undecidable |
The Inbox Item carries payload.kind = "workflow.gate" as a discriminator plus workflowRunId, workflowName, workflowState for the UI.
Timeout mechanism: if timeoutSeconds: is set, the Executor creates a timer entry with firedOutcome = "timeout" in addition to the Inbox Item. Two race paths compete:
- User answers first → Inbox Listener fires Outcome → Timer fire is later discarded by
appendIfAbsentidempotence. - Timer fires first →
outcome=timeout, Run advanced viacatch.timeout:. Subsequent User answer still lands in the Inbox, but the Run no longer reacts.
3.5 timer_task
wait_for_feedback:
type: timer_task
duration: "7d"
on:
fired: send_reminder
duration: parser format: ISO-8601 (P7D, PT5M30S) or shortcuts (7d, 4h, 30m, 45s, 250ms).
Outcome: always fired. Timers do not fail — the scanner path is deterministic.
3.6 condition_task
route_by_risk:
type: condition_task
transitions:
- if: "#state['plan_output']['risk'] == 'low'"
to: merge
- if: "#state['plan_output']['risk'] == 'high'"
to: review
- else: triage
Pure SpEL evaluation. The first matching rule wins; else: must be the last rule (Loader validation).
SpEL variables:
#state['<key>']— Workflow variables from previousstoreAs:writes.#params['<key>']— Caller parameters (set atstart()).#tasks['<state>']['output']— reserved for future use.
Sandbox: T(...), new ... and method calls are blocked. Operators ==, !=, <, <=, >, >=, &&, ||, !, in {...}, matches '<regex>', ternary ? :, Elvis ?: are allowed.
3.7 workflow_task
build_subprojects:
type: workflow_task
workflow: build-and-test
params:
repo_url: "${state.repo_url}"
storeAs: build_result
timeoutSeconds: 3600
on:
success: deploy
catch:
failure: escalate
Spawns a Sub-Run and waits for its Terminal. Variable transfer:
- Incoming (Parent → Sub): only via
params:. No implicit inheritance mechanism. - Outgoing (Sub → Parent): the Terminal State of the Sub-Run writes
ResultRecord(result). The Parent captures this viastoreAs:.
Parent identity (workflowRunId, state-name) is persisted in StartRecord.parentMagratheaProcessId + parentState of the Sub-Run — audit chain through arbitrarily deep nesting.
Outcome: success (Sub DONE), failure (Sub FAILED/TERMINATED).
3.8 terminal
done:
type: terminal
outcome: success # success | failure (Default success)
result:
summary: "..."
End node. Writes:
StatusRecord(DONE)ifoutcome: success,StatusRecord(FAILED)otherwise.ResultRecord(state, result)ifresult:is set.- For Sub-Workflows: triggers
WorkflowCompletedEventto the Parent.
Multiple Terminal States are allowed and common — typical names: done, merged, escalated, aborted.
4. State Transitions
4.1 Resolver Order
nextStateOverride— onlycondition_tasksets this.on:— exact string match against Outcome.catch:— Outcome interpreted asErrorKindenum.- No Match → Run failed with
StatusRecord(FAILED, reason="no transition for outcome '<x>'").
4.2 ErrorKind Vocabulary
Seven categories that catch: can understand:
| Kind | When |
|---|---|
technical_error |
Tool/API/Shell infra broken (IOException, 5xx, …). |
business_error |
Expected business error (exit !=0, Validation fail). |
agent_error |
LLM produced invalid output (Jeltz schema_violation). |
timeout |
Task timeout or Gate timeout. |
permission_error |
Tool not allowed for this Workflow/Caller. |
human_rejected |
Alias for gate-rejected for uniform catching. |
cancelled |
Workflow stopped by cancel/Bounds exhaustion. |
4.3 Retry Preempts Resolution
flaky:
type: shell_task
run: "npm test"
retry:
maxAttempts: 3
on: [technical_error, timeout]
backoffSeconds: 60
on:
success: review
catch:
technical_error: escalate
Order in handleCompletion:
TaskResultRecordwrites (idempotent).VarRecordforstoreAs:.- Terminal special case (StatusRecord+ResultRecord if
type: terminal). - Retry Check: if Outcome matches
retry.on:andretryCount+1 < maxAttempts→ re-enqueue in the same State withretryCount+1andbackoffSeconds. Catch is skipped. - Bounds Check (§9).
- Transition Resolver (on → catch → fail).
maxAttempts counts including the first attempt. maxAttempts: 3 = original + 2 retries.
5. Lifecycle of a Workflow Run
start(name, params, startedBy)
↓
StartRecord (with Frozen-YAML, parent-Identity if Sub-Run)
StateEnteredRecord(<start-state>)
magrathea_tasks.insert(PENDING)
↓
Claim-Scanner (every 2s, per Pod):
CLAIMED → ProjectLane.submit(execute)
↓
TaskExecutor:
TaskStartedRecord
TypeExecutor.execute()
↓
├── Sync: TaskCompletedEvent directly
└── Async: WAITING_*, Listener fires later
↓
handleCompletion (on the Project Lane):
TaskResultRecord (idempotent appendIfAbsent)
VarRecord for storeAs
Terminal special case OR:
Retry Check → re-enqueue same state
Bounds Check → fail run
Transition resolve → enqueue next state
↓
... until Terminal State
↓
StatusRecord(DONE/FAILED) — Run completed.
Each Run has its own workflowRunId (full 32-hex UUID, dash-stripped). A maximum of one Task runs simultaneously per Run — Tasks are serialized via the Project Lane (§11).
6. Cascade — How Workflows are Resolved
project/_vance/workflows/<name>.yaml (project-local override)
↓ falls back to
_tenant-tenant/_vance/workflows/<name>.yaml (tenant-wide)
↓ falls back to
UnknownWorkflowException
There is no Resource Tier (Classpath/Bundled) for Workflows. Workflows are always project- or tenant-specific. Bundled examples are distributed via Kits (see kits) — a Kit copies its documents/_vance/workflows/*.yaml into the Document Layer of the target Project.
The Resolver reads directly from Mongo for each start(). There is no refresh endpoint — unlike Schedulers, where the Cron Registry must be maintained separately.
7. Frozen Snapshot
At start(), the Service copies the entire YAML body into StartRecord.definitionYaml. Each subsequent Task execution re-parses from this snapshot.
Consequences:
- Edits to the source document do not affect running Runs.
- Long Workflows (days/weeks with Gates) are robust against mid-flight schema drift.
- Bug fix rollouts: new Run first, old Runs must be explicitly canceled.
- Audit:
StartRecord.definitionYamlis the authoritative answer to “what did this Run execute”, even if the source document was deleted or rewritten.
8. Trigger Paths
8.1 Agent Tool workflow_start
# Tool Schema
name: workflow_start
params:
name: { type: string }
params: { type: object, optional }
Not in the Recipe Pool by default. Engines that are allowed to start Workflows (typically Marvin/Vogon, not Arthur-Chat) include it in allowedToolsAdd.
Return value: { workflowRunId, workflowName }.
8.2 REST
POST /brain/{tenant}/project/{project}/workflows/{name}/start
Body: { params: {...}, startedBy: "..." }
Reply: { workflowRunId, workflowName }
GET /brain/{tenant}/project/{project}/workflows/runs/{runId}
Reply: MagratheaProcessDto (status, currentState, vars, params, result, timestamps)
GET /brain/{tenant}/project/{project}/workflows/runs?workflow=<name>
Reply: List<MagratheaProcessDto>, newest first, max 100
GET /runs/{runId} performs a Tenant/Project cross-check and returns 404 (not 403) if the Run belongs to a different Scope — existence is not leaked.
8.3 WebSocket
MessageType.WORKFLOW_START — Symmetrical to REST, runs over the client’s bound Session (Foot, Web UI).
client → brain: { type: "workflow-start", data: { name, params?, startedBy? } }
brain → client: { type: "workflow-start", data: { workflowRunId, workflowName } }
8.4 Scheduler Trigger
A Scheduler Doc (_vance/scheduler/<name>.yaml) can carry a workflow: field instead of recipe: — see scheduler §4.2. On a Cron tick (or one-shot at:), the UrsaSchedulerService calls MagratheaWorkflowService.start(tenantId, projectId, workflowName, params, runAs). The Scheduler Event Log receives the workflowRunId as a payload field, so the Web UI can reconstruct the connection between the Scheduler tick and the Workflow Run.
# _vance/scheduler/daily-audit.yaml
description: "Start Daily Audit Workflow at 06:00"
cron: "0 0 6 * * *"
workflow: daily-audit # instead of recipe:
params:
scope: "production"
runAs: "ops"
recipe: and workflow: are mutually exclusive — setting both results in a parse error from the UrsaSchedulerLoader.
8.5 Ursahook Trigger
Ursahooks (see ursahooks) can start Workflows from their host API — both JS Ursahooks and LLM Ursahooks (via the workflow.start action in the structured action schema).
JS Ursahook:
// hooks.js
const runId = workflows.start("pr-review", { pr_url: event.pr.url });
log.info("started workflow", { runId });
LLM Ursahook (structured action):
{ "kind": "workflow.start", "name": "pr-review", "params": { "pr_url": "..." } }
The UrsaHookDispatcher pins tenantId/projectId from the Ursahook Scope — scripts cannot spawn cross-tenant or cross-project. startedBy is set to "hook:<hookName>" so that the Workflow Run in the Journal can be uniquely attributed to the triggering Ursahook.
If Magrathea is not active (vance.services.magrathea=false), workflows.start() returns null and logs WARN — the script decides how to react.
8.6 Event Trigger (External, REST)
Events are the external, REST-accessible trigger path — GET|POST /brain/{tenant}/event/{project}/{event}, JWT-free, optionally Bearer-authenticated. An Event Doc carries only workflow: name + Auth Config + static params; a POST body is passed to the Workflow under params.payload. See events.
# _vance/events/github-pr.yaml
description: "GitHub PR Hook → pr-review"
workflow: pr-review
methods: [POST]
auth:
tokenSetting: events.github.token
Events are explicitly without rate limits, signature validation, or replay protection — operator responsibility, or via a dedicated provider receiver before the Brain.
8.7 Planned (Outside v1)
- Provider-specific Webhook Receivers (GitHub
X-Hub-Signature-256, Stripe signature, …) with built-in signature verification, pre-positioned before the Event Endpoint. - Async/SSE response for Events that want to wait for Workflow completion.
9. Bounds
bounds:
maxTotalCostUsd: 5.0 # reserved, see below
maxWallclockSeconds: 604800 # 7 days hard limit
maxTaskSpawns: 100 # absolute cap including retries
The Bounds Check runs after each Task completion and before the next enqueue. If a Bound is exceeded, the Service writes StatusRecord(FAILED, reason="bounds exhausted: …") and terminates the Run. Bounds do not route via catch: — they are a hard stop.
| Bound | What it checks |
|---|---|
maxWallclockSeconds |
now - firstJournalEntry.createdAt > maxWallclockSeconds. |
maxTaskSpawns |
Number of TaskStartedRecord entries in the Journal. Retries count. |
maxTotalCostUsd |
Reserved for LLM resource management integration; not enforced in v1. |
10. Tool Permission Cascade
allowed = tenant.allowedTools ∩ project.allowedTools ∩ workflow.allowedTools
denied = tenant.denied ∪ project.denied (overrides any allow)
Three layers, AND-combined. Workflow definitions can only narrow the Project Pool, never expand it. If no allowedTools: is declared, the default pool is used without side effects (web_search, doc_read, doc_list, inbox_read).
Tool calls in tool_task and indirectly from agent_task sub-Engines are checked against this pool.
11. Pod Lane Serialization
A maximum of one ProjectLane runs simultaneously per Project — a single-thread executor per (projectId). Each Task execution, each completion processing, and each transition resolution runs on this Lane. Race protection for the variable map and the Task queue comes from Lane serialization.
Tasks are claimed cross-pod via Mongo optimistic locking (CAS on version). A TaskClaimer runs per Pod (every 2s) that claims PENDING Tasks and throws them into the local ProjectLane. If a Pod dies, the next claim attempt by another Pod takes over (see §12).
Within-Run serialization: for each workflowRunId, a maximum of one Task is in the queue at any time, because the next Task is only enqueued upon completion of the current one.
12. Reclaim & Heartbeat
Pod crash resilience:
- WAITING_*-Tasks (
agent_task/gate_task/timer_task/workflow_task) normally do not need reclaim — their completion listeners fire independently of the Pod. A 7-day Gate on a dead Pod is forwarded by the Inbox Listener on another Pod. ExceptionWAITING_SUBPROCESS: if the in-memory completion event is lost during a Pod crash, the Task would become permanently stuck — a recovery scanner (ReclaimScanner.recoverLostSubprocessCompletions, after a 5-minute grace period) reconciles such Tasks against the persisted ThinkProcess status. - Sync-Tasks (
condition_task/tool_task/shell_task/script_task/terminal) withrunStatus = nullcan become stale if the Pod dies in the middle of execution. TheReclaimScanner(every 60s, per Pod) findsCLAIMEDTasks withclaimedAt < now - 5minand re-enqueues them asPENDING(Optimistic-CAS). - Heartbeat for long-running Sync Executors: the Dispatcher (
MagratheaTaskExecutor) automatically pingstouchHeartbeat(taskId)every 60s while a synchronous Type Executor is running — no Executor calls this itself. The Scanner respectsheartbeatAt, so a Task running longer than the Reclaim Grace is not falsely executed twice; a crash stops the Heartbeat, so a truly dead Task will still become stale. - Exhausted attempts: after
maxClaimAttempts(default 3), the Task fails terminally withoutcome=technical_error— thecatch:block routes if declared.
13. Web UI
- Listing:
GET /workflows/runsreturns a MagratheaProcessDto list, newest first. - Detail:
GET /workflows/runs/{runId}provides status, currentState, vars, params, result, timestamps. - Workflow YAML Editor: runs via the generic Document Editor (path
_vance/workflows/).
Cancel, Event Stream, and Live Updates are v2 — the Snapshot Endpoint is sufficient for the v1 “what’s running now?” view and is inexpensive (one Journal read).
14. Implementation Notes
Java implementation under de.mhus.vance.brain.magrathea.* (Service Layer) and de.mhus.vance.shared.magrathea.* (Persistence). The name Magrathea (Adams universe: a computer of sub-computers that solves tasks by composition) is the internal naming; user-facing, all surfaces remain named “Workflow”.
Mongo Collections:
| Collection | Content |
|---|---|
magrathea_journal |
Append-only Audit Records per Run. Indexed on (tenantId, projectId, workflowRunId, createdAt), plus partial-unique index on (tenantId, projectId, workflowRunId, taskId, type=TaskResultRecord) for idempotent Completion append. Reads are consistently tenant/project-scoped (no longer purely via workflowRunId). |
magrathea_tasks |
Pending/Claimed/Done Tasks. Indexed on (projectId, status, nextAttemptAt) for Claim Scan, (claimedBy, claimedAt) for Reclaim. |
magrathea_timers |
Pending Timers with linkedTaskId + firedOutcome. Indexed on (firedAt, fireAt) for Scanner, unique on linkedTaskId. |
Service activation via vance.services.magrathea: true in application.yml. Anus modules (CLI bootstrap) do not start the Workflow Service.
Full implementation details: planning/workflow-service.md.
15. What v1 DOES NOT do
- External Webhooks. External HTTP calls would require a Gateway module with Auth/Rate Limit/Tenant Routing — the Event subsystem for this does not yet exist.
- Cross-Project Workflows. A Workflow lives within the scope of a Project. Cross-Project is only possible via
workflow_startTool calls with explicit cross-project routing. - Inline Recipe Definitions in YAML. Tasks only reference Recipe names.
- Parallel Branches (Fan-Out / Fan-In). Currently only sequential transitions. For parallelism: multiple
workflow_tasksub-spawns + manual aggregation. - Versioned Run Migration. If a Workflow definition is changed, active Runs continue on their Frozen Snapshot.
- Cost Aggregation per Run.
bounds.maxTotalCostUsdrequires LLM resource management integration. - Live Event Stream in the Web UI. Snapshot refresh instead of push.
- Visual Workflow Editor. YAML authoring via the Document Editor.
16. Open Items
- Cross-Project Global Locks. Variables live per Run; a “only one deploy at a time” lock across multiple Workflows requires a separate Project-Level Lock Collection.
- Cross-Pod ThinkProcess Termination. Currently, the ThinkProcess must live on the same Pod as its parent-Workflow (Project Lane affinity). If ThinkProcesses are later deployed cross-pod, the
ThinkProcessCompletionListenerneeds a Mongo-mediated sync path. condition_taskFast-Path. v1 writes aTaskStartedRecord+TaskResultRecordfor every 1-µs eval. A hot path could combine both records into a single Mongo write.- Auto-Default for
criticality: LOWGates. Vogon §2.3 has the pattern; Magrathea v1 does not implement it. - Listing Pagination.
GET /runsis capped at 100 results. True pagination will come when a Project regularly has many Runs.
17. Predecessor Concept (Historical)
Before the Magrathea Workflow subsystem, there was another “Workflow” concept: Templates for Marvin Task Trees with fixed: nodes that the LLM was not allowed to restructure. This idea was absorbed into the Marvin Engine itself — see marvin-engine. The term “Workflow” has since belonged to the Magrathea State Machine subsystem.
instructions/workflows.md (design discussion from 2026-05-14) is the source of the Magrathea concept documented here.