Files
lobster/README.md
Yiğit ERDOĞAN 096f5fedcb fix: workflow steps keep waiting for the model after timeout_ms expires (#132)
* fix(llm): honor step timeouts and cancellation during model calls

llm.invoke never read ctx.signal, and both adapter helpers called fetch
without one, so nothing could end a request once it was in flight. A
workflow step with timeout_ms kept waiting for the adapter and, when the
model eventually answered, reported the step as completed well past its
budget. Shell steps already honor the same signal, so the two step kinds
disagreed about what timeout_ms means.

Thread ctx.signal into both adapter fetch calls, check it before each
attempt so an already-cancelled run does not pay for another call, and
rethrow abort errors unwrapped: the workflow runner recognizes timeouts
and external cancellation by the error's identity, so wrapping them in
"request failed" would misreport the outcome even once the request stops.

* fix(llm): stop waiting for adapters that ignore the cancellation signal

Threading the signal into fetch only covers the HTTP adapters. An injected
ctx.llmAdapters adapter is awaited directly, so one that never observes
ctx.signal still holds a timed-out step open for as long as it likes, which
leaves SDK and tool-runtime users on the weaker timeout contract the rest of
this change removes.

Await the adapter call against the signal instead of the adapter alone. The
adapter's own work cannot be killed from here, but the step stops waiting on
it and the abort reason reaches the workflow unchanged. Cooperative adapters
are unaffected: they still receive ctx and can end their own work.

* fix(llm): do not finish a cancelled run from cached results

Run-state and file-cache hits return before any adapter call, so a run that
was already cancelled still completed successfully from cached data. That
contradicts the rest of this change: the same invocation reports cancellation
when it has to reach a model and success when it does not, purely on cache
contents.

Check the signal once at entry, before either lookup. The retry-loop check
stays, since cancellation can also arrive between validation attempts.

* docs(readme): state the cancellation contract for injected LLM adapters

Racing an adapter promise against the step signal restores workflow liveness,
but it cannot cancel work the adapter has already started: an injected adapter
that ignores `ctx.signal` keeps its model request running after Lobster stops
awaiting it, and a configured retry can then overlap it. That is a contract for
the host to meet, so the host-facing docs have to say it rather than leaving it
to be discovered from a duplicated charge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Bu6MvbjKGtfRiNesARMyZ

* fix(llm): re-check cancellation after each awaited reuse boundary

The entry check only caught a run that was already cancelled when the
command started. Draining pipeline input waits on the upstream step, and
the run-state and cache lookups are file I/O, so a step timeout could
fire during any of them and the reuse branch would still return its
stored answer as a successful result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gD5sJTbh2jn1uvgNCmkLq

* fix(llm): do not report a step that finished after its deadline

The cancellation checks stopped at the adapter call, but both return
paths then await run-state and cache writes. A deadline crossed during
those writes was never observed, so the step returned success and the
workflow cleared its timer -- a model step could still complete late.

The check goes after both writes rather than between them: the answer
has already been paid for, and leaving it stored lets the retry replay
it instead of calling the model a second time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gD5sJTbh2jn1uvgNCmkLq

* test(llm): let the persistence tests run where the state fix has not landed

The two cancellation-during-write tests let the command create the cache
namespace directory itself, which is where #125 throws on Windows: the
recursive mkdir reports an extended-length path and the chain sync then
opens `<dir>\C:`. Both tests failed there before reaching an assertion,
so this branch added two Windows failures over main for a reason that is
not its own.

Creating the directory up front is what the seeded-cache tests in this
file already do. ensureDirectory only syncs a chain it created, so an
existing directory never reaches the broken path, and the tests still
fail against the code they cover -- now on the missing rejection rather
than on ENOENT.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwU1bvb9Pt3cR662P52CTp

* fix(llm): let a host's own abort reason still read as cancellation

The workflow runner classifies cancellation by name alone: only
`AbortError` or `ABORT_ERR` bypasses the step's retry and `on_error`
policy. Every abort path here rejected with `signal.reason` untouched,
so a host that calls `controller.abort(new Error("stop"))` handed the
runner an ordinary-looking error. A cancelled `llm.invoke` step under
`on_error: continue` was recorded as a step failure and the run carried
on -- returning `status: "ok"` for a run the host had cancelled.

The gap is narrow, which is why it survived earlier review. An HTTP
adapter is saved by fetch rejecting with its own `AbortError`, and the
runner's own step timer is not affected. It opens only on the injected
`ctx.llmAdapters` path, where this command's rejection is the only thing
the runner ever sees.

Normalize at the boundary instead of guessing upstream: a reason that
already reads as an abort passes through untouched, anything else keeps
its message and moves to `cause` under an abort-identified error. The
host's reason is never discarded, and callers that already match on
`AbortError` keep matching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHhk7xRwXzYcSXvw2kp5F5

* fix(llm): watch an adapter that rejects after aborting the run

`abortable` guards against an adapter that ignores `ctx.signal`, but its
already-aborted branch rejected the wrapper and returned before anything
was attached to the adapter's own promise. An adapter that cancels the
run from inside its own `invoke` -- an SDK client tearing itself down on
a fatal error -- and then rejects leaves that rejection unobserved.

The step still fails correctly, with the `AbortError` the runner needs.
The damage lands afterwards: under Node's default unhandled-rejection
handling the process exits on an error nothing is waiting for, seconds
after the run was cancelled cleanly.

Attach settlement handling first in both branches, so the promise is
watched before an abort can win the race. Removing a listener that was
never added is harmless, which keeps the two paths one shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hFZxjivm9JTTvmRjQ4A8S

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:28:51 -07:00

369 lines
13 KiB
Markdown

# 🦞 Lobster
![Lobster banner](docs/assets/readme-banner.jpg)
An OpenClaw-native workflow shell: typed (JSON-first) pipelines, jobs, and approval gates.
## Example of Lobster at work
OpenClaw (or any other AI agent) can use `lobster` as a workflow engine and avoid re-planning every step — saving tokens while improving determinism and resumability.
### Watching a PR that hasn't had changes
```
node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo\":\"openclaw/openclaw\",\"pr\":1152}'"
[
{
"kind": "github.pr.monitor",
"repo": "openclaw/openclaw",
"prNumber": 1152,
"key": "github.pr:openclaw/openclaw#1152",
"changed": false,
"summary": {
"changedFields": [],
"changes": {}
},
"prSnapshot": {
"author": {
"id": "MDQ6VXNlcjE0MzY4NTM=",
"is_bot": false,
"login": "vignesh07",
"name": "Vignesh"
},
"baseRefName": "main",
"headRefName": "feat/lobster-plugin",
"isDraft": false,
"mergeable": "MERGEABLE",
"number": 1152,
"reviewDecision": "",
"state": "OPEN",
"title": "feat: Add optional lobster plugin tool (typed workflows, approvals/resume)",
"updatedAt": "2026-01-18T20:16:56Z",
"url": "https://github.com/openclaw/openclaw/pull/1152"
}
}
]
```
### And a PR that has a state change (in this case an approved PR)
```
node bin/lobster.js "workflows.run --name github.pr.monitor --args-json '{\"repo\":\"openclaw/openclaw\",\"pr\":1200}'"
[
{
"kind": "github.pr.monitor",
"repo": "openclaw/openclaw",
"prNumber": 1200,
"key": "github.pr:openclaw/openclaw#1200",
"changed": true,
"summary": {
"changedFields": [
"number",
"title",
"url",
"state",
"isDraft",
"mergeable",
"reviewDecision",
"updatedAt",
"baseRefName",
"headRefName"
],
"changes": {
"number": {
"from": null,
"to": 1200
},
"title": {
"from": null,
"to": "feat(tui): add syntax highlighting for code blocks"
},
"url": {
"from": null,
"to": "https://github.com/openclaw/openclaw/pull/1200"
},
"state": {
"from": null,
"to": "MERGED"
},
"isDraft": {
"from": null,
"to": false
},
"mergeable": {
"from": null,
"to": "UNKNOWN"
},
"reviewDecision": {
"from": null,
"to": ""
},
"updatedAt": {
"from": null,
"to": "2026-01-19T05:06:09Z"
},
"baseRefName": {
"from": null,
"to": "main"
},
"headRefName": {
"from": null,
"to": "feat/tui-syntax-highlighting"
}
}
},
"prSnapshot": {
"author": {
"id": "MDQ6VXNlcjE0MzY4NTM=",
"is_bot": false,
"login": "vignesh07",
"name": "Vignesh"
},
"baseRefName": "main",
"headRefName": "feat/tui-syntax-highlighting",
"isDraft": false,
"mergeable": "UNKNOWN",
"number": 1200,
"reviewDecision": "",
"state": "MERGED",
"title": "feat(tui): add syntax highlighting for code blocks",
"updatedAt": "2026-01-19T05:06:09Z",
"url": "https://github.com/openclaw/openclaw/pull/1200"
}
}
]
```
## Goals
- Typed pipelines (objects/arrays), not text pipes.
- Local-first execution.
- No new auth surface: Lobster must not own OAuth/tokens.
- Composable macros that OpenClaw (or any agent) can invoke in one step to save tokens.
## Quick start
From this folder:
- `pnpm install`
- `pnpm test`
- `pnpm lint`
- `node ./bin/lobster.js --help`
- `node ./bin/lobster.js doctor`
- `node ./bin/lobster.js "exec --json --shell 'echo [1,2,3]' | where '0>=0' | json"`
### Notes
- `pnpm test` runs `tsc` and then executes tests against `dist/`.
- `bin/lobster.js` prefers the compiled entrypoint in `dist/` when present.
## Commands
- `exec`: run OS commands
- `exec --stdin raw|json|jsonl`: feed pipeline input into subprocess stdin
- `where`, `pick`, `head`: data shaping
- `json`, `table`: renderers
- `approve`: approval gate (TTY prompt or `--emit` for OpenClaw integration)
## Next steps
- OpenClaw integration: ship as an optional OpenClaw plugin tool.
## Workflow files
Lobster workflow files are meant to read like small scripts:
- `run:` or `command:` for deterministic shell/CLI steps
- `pipeline:` for native Lobster stages like `llm.invoke`
- `approval:` for hard workflow gates between steps
- `stdin: $step.stdout` or `stdin: $step.json` to pass data forward
```
lobster run path/to/workflow.lobster
lobster run --file path/to/workflow.lobster --args-json '{"tag":"family"}'
```
Example file:
```yaml
name: jacket-advice
args:
location:
default: Phoenix
steps:
- id: fetch
run: weather --json ${location}
- id: confirm
approval: Want jacket advice from the LLM?
stdin: $fetch.json
- id: advice
pipeline: >
llm.invoke --prompt "Given this weather data, should I wear a jacket?
Be concise and return JSON."
stdin: $fetch.json
when: $confirm.approved
```
Notes:
- `run:` and `command:` are equivalent; `run:` is the preferred spelling for new files.
- `pipeline:` shares the same args/env/results model as shell steps, so later steps can still reference `$step.stdout` or `$step.json`.
- If you need a human checkpoint before an LLM call, use a dedicated `approval:` step in the workflow file rather than `approve` inside the nested pipeline.
- `cwd`, `env`, `stdin`, `when`, and `condition` work for both shell and pipeline steps.
- Use `retry`, `timeout_ms`, and `on_error` per step to control transient-failure behavior and recovery.
- Approval steps can optionally enforce identity constraints:
- `approval.required_approver` (or `requiredApprover`) requires an exact approver id.
- `approval.require_different_approver` (or `requireDifferentApprover`) requires approver id to differ from initiator.
- `approval.initiated_by` (or `initiatedBy`) sets the initiator id for comparison.
- `LOBSTER_APPROVAL_INITIATED_BY` can provide a default initiator id at run time.
- `LOBSTER_APPROVAL_APPROVED_BY` is used at resume/approval time for identity checks.
### Command-level input requests
Pipeline commands can call `ctx.requestInput({ prompt, responseSchema, defaults, subject, suspendedState })` to pause in tool mode, workflows, or the SDK and resume the same command after a structured response. CLI/tool resume tokens store only a state key; the persisted state validates the suspended request metadata before returning the submitted response to the command. SDK same-command resumes store the command frame in the configured SDK state directory.
Commands are re-run on resume, so they must be idempotent until `requestInput` returns. Array-backed command input is snapshotted with bounds for replay; lazy stream input is not buffered and requires a compact JSON `suspendedState` supplied by the command. On resume, call `ctx.requestInput.getSuspendedState()` before reading lazy input to restore that command-owned continuation state.
## Visualizing workflows
Use `lobster graph` to inspect workflow structure before execution.
```bash
lobster graph --file path/to/workflow.lobster
lobster graph --file path/to/workflow.lobster --format mermaid
lobster graph --file path/to/workflow.lobster --format dot
lobster graph --file path/to/workflow.lobster --format ascii
lobster graph --file path/to/workflow.lobster --args-json '{"location":"Seattle"}'
```
What gets visualized:
- each workflow step as a node (`run`, `pipeline`, `approval`, etc.)
- data-flow edges from `stdin: $step.stdout` / `$step.json` references
- conditional dependencies from `when:` / `condition:` expressions
- approval gates as diamond-shaped nodes in `mermaid` and `dot` output
Format notes:
- `mermaid` (default): emits `flowchart TD` text for GitHub/Markdown rendering
- `dot`: emits Graphviz DOT syntax
- `ascii`: emits a terminal-friendly node/edge list
## Calling LLMs from workflows
Use `llm.invoke` from a native `pipeline:` step for model-backed work:
```bash
llm.invoke --prompt 'Summarize this diff'
llm.invoke --provider openclaw --prompt 'Summarize this diff'
llm.invoke --provider pi --prompt 'Summarize this diff'
```
Provider resolution order:
- `--provider`
- `LOBSTER_LLM_PROVIDER`
- auto-detect from environment
Built-in providers today:
- `openclaw` via `OPENCLAW_URL` / `OPENCLAW_TOKEN`
- `pi` via `LOBSTER_PI_LLM_ADAPTER_URL` (typically supplied by the Pi extension)
- `http` via `LOBSTER_LLM_ADAPTER_URL`
A host embedding Lobster can supply its own adapters through `ctx.llmAdapters`. Step `timeout_ms` and workflow cancellation reach an adapter as `ctx.signal`: Lobster stops waiting as soon as that signal aborts, so the step fails or retries on time either way, but it cannot cancel work an adapter has already started. An injected adapter should observe `ctx.signal` and abort its own request — otherwise a timed-out step can leave a model call running, and billed, in the background.
Workflow `_meta.cost` and `cost_limit` use a static pricing table plus optional overrides from `LOBSTER_LLM_PRICING_JSON`, for example `{"my-model":{"input":1.0,"output":2.0}}` in USD per million tokens. Unknown or missing model IDs still record token counts with zero estimated cost, but Lobster warns on stderr so stale or missing pricing does not fail silently.
A cached or replayed answer is not billed again: a model call is counted once, in the run that made it, however many later steps re-emit its answer. This holds while the answer stays inside Lobster: through pipelines, renderers, projections, run state, `workflow:` steps and a resume. It does not survive a stage that hands the items to an external process and reads them back — `exec --stdin json --json ...` — because what comes back is whatever that process printed, and Lobster cannot tell a faithful copy of a replay from a fresh claim to have made the call. Such a step is billed as a call, which is what earlier versions did everywhere. A `workflow:` step counts what its sub-workflow spent, and a replay the sub-workflow returned is not billed a second time by the run that composed it. Spend also survives a pause — a workflow that stops at an approval or `input` gate keeps what it has recorded, so `_meta.cost` covers the whole run after a resume and `cost_limit` applies to the whole run rather than to the steps after the last gate.
`llm_task.invoke` remains available as a backward-compatible alias for the OpenClaw provider.
### Calling configured OpenClaw agents
Use `openclaw.agent` when a workflow needs a configured OpenClaw agent rather than a direct model call:
```bash
openclaw.agent --agent ops --prompt 'Summarize these logs'
openclaw.agent --agent ops --session-key incident-42 --model openai/gpt-5.4 --prompt 'Continue the investigation'
```
The command delegates agent identity, model defaults and overrides, sessions, authentication, and execution to the installed `openclaw agent` CLI. It accepts `--agent`, `--session-key`, `--session-id`, `--model`, `--thinking`, `--timeout`, and `--local`, and returns OpenClaw's structured `--json` response. Pipeline input is appended to the prompt as labeled JSONL.
### `pipeline:` vs `run:` for LLM calls
- Use `pipeline:` for `openclaw.agent`, `llm.invoke`, and `llm_task.invoke` (they are Lobster pipeline stages, not shell executables).
- Use `run:` only for real binaries in your shell (for example `openclaw.invoke`).
Example (`stdin` from a prior step is passed to the LLM as artifacts):
```yaml
steps:
- id: make_words
run: echo "One two three four five six"
- id: count_words
pipeline: llm_task.invoke --prompt "How many words have been pasted below?"
stdin: $make_words.stdout
```
## Calling OpenClaw tools from workflows
Shell `run:` steps execute in your system shell, so OpenClaw tool calls there must be real executables.
If you install Lobster via npm/pnpm, it installs a small shim executable named:
- `openclaw.invoke` (preferred)
- `clawd.invoke` (alias)
These shims forward to the Lobster pipeline command of the same name.
### Example: invoke llm-task
Prereqs:
- `OPENCLAW_URL` points at a running OpenClaw gateway
- optionally `OPENCLAW_TOKEN` if auth is enabled
```bash
export OPENCLAW_URL=http://127.0.0.1:18789
# export OPENCLAW_TOKEN=...
```
In a workflow:
```yaml
name: hello-world
steps:
- id: greeting
run: >
openclaw.invoke --tool llm-task --action json --args-json '{"prompt":"Hello"}'
```
### Passing data between steps (no temp files)
Use `stdin: $stepId.stdout` to pipe output from one step into the next.
## Args and shell-safety
`${arg}` substitution is a raw string replace into the shell command text.
For anything that may contain quotes, `$`, backticks, or newlines, prefer env vars:
- every resolved workflow arg is exposed as `LOBSTER_ARG_<NAME>` (uppercased, non-alnum → `_`)
- the full args object is also available as `LOBSTER_ARGS_JSON`
Example:
```yaml
args:
text:
default: ""
steps:
- id: safe
env:
TEXT: "$LOBSTER_ARG_TEXT"
command: |
jq -n --arg text "$TEXT" '{"result": $text}'
```