@luckydraw/cumulus 0.31.66 → 1.0.0
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/CHANGELOG.md +6 -559
- package/LICENSE +150 -0
- package/README.md +27 -8
- package/dist/gateway/adapters/webchat.d.ts +2 -0
- package/dist/gateway/adapters/webchat.d.ts.map +1 -1
- package/dist/gateway/adapters/webchat.js +22 -2
- package/dist/gateway/adapters/webchat.js.map +1 -1
- package/dist/gateway/config.d.ts +17 -2
- package/dist/gateway/config.d.ts.map +1 -1
- package/dist/gateway/config.js +10 -3
- package/dist/gateway/config.js.map +1 -1
- package/dist/gateway/daemon.d.ts +3 -1
- package/dist/gateway/daemon.d.ts.map +1 -1
- package/dist/gateway/daemon.js +128 -39
- package/dist/gateway/daemon.js.map +1 -1
- package/dist/gateway/namespaces.d.ts +34 -0
- package/dist/gateway/namespaces.d.ts.map +1 -1
- package/dist/gateway/namespaces.js +58 -0
- package/dist/gateway/namespaces.js.map +1 -1
- package/dist/gateway/server.d.ts +8 -0
- package/dist/gateway/server.d.ts.map +1 -1
- package/dist/gateway/server.js +150 -41
- package/dist/gateway/server.js.map +1 -1
- package/dist/gateway/setup.d.ts +32 -0
- package/dist/gateway/setup.d.ts.map +1 -1
- package/dist/gateway/setup.js +23 -3
- package/dist/gateway/setup.js.map +1 -1
- package/dist/gateway/static/widget.js +897 -611
- package/dist/lib/gateway.d.ts +30 -8
- package/dist/lib/gateway.d.ts.map +1 -1
- package/dist/lib/gateway.js +36 -11
- package/dist/lib/gateway.js.map +1 -1
- package/dist/lib/history.d.ts +22 -0
- package/dist/lib/history.d.ts.map +1 -1
- package/dist/lib/history.js +59 -21
- package/dist/lib/history.js.map +1 -1
- package/dist/lib/huggingface-provider.d.ts.map +1 -1
- package/dist/lib/huggingface-provider.js +11 -3
- package/dist/lib/huggingface-provider.js.map +1 -1
- package/dist/lib/license.d.ts +76 -0
- package/dist/lib/license.d.ts.map +1 -0
- package/dist/lib/license.js +141 -0
- package/dist/lib/license.js.map +1 -0
- package/docs/agentic-harness-primer.md +283 -0
- package/docs/conditional-continuation.md +167 -0
- package/docs/web-app-agent-guide.md +520 -0
- package/examples/web-app-agent/README.md +187 -0
- package/examples/web-app-agent/agent/mcp-shim.js +105 -0
- package/examples/web-app-agent/gateway.config.example.json +52 -0
- package/examples/web-app-agent/package.json +13 -0
- package/examples/web-app-agent/public/agent/bridge-mount.js +75 -0
- package/examples/web-app-agent/public/agent/chat-client.js +104 -0
- package/examples/web-app-agent/public/agent/commands.js +250 -0
- package/examples/web-app-agent/public/agent/device-thread.js +48 -0
- package/examples/web-app-agent/public/agent/panel.css +107 -0
- package/examples/web-app-agent/public/agent/panel.js +369 -0
- package/examples/web-app-agent/public/app.js +250 -0
- package/examples/web-app-agent/public/index.html +111 -0
- package/examples/web-app-agent/server.js +242 -0
- package/package.json +7 -3
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# Cumulus Agentic Harness — A Primer (for OSS models like Kimi)
|
|
2
|
+
|
|
3
|
+
> A plain-language explanation of how Cumulus turns a _plain_ language model into an
|
|
4
|
+
> _agent_ that can read files, run tools, ask questions, and work through multi-step
|
|
5
|
+
> tasks — without relying on the Claude CLI. No code; concepts only.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Why this exists
|
|
10
|
+
|
|
11
|
+
Cumulus originally drove every turn by spawning Anthropic's `claude` command-line
|
|
12
|
+
tool. That CLI hides a lot of machinery: it decides when to call tools, when to stop,
|
|
13
|
+
how to stream output, how to recover from truncation. It only works with Claude.
|
|
14
|
+
|
|
15
|
+
The **agentic harness** is Cumulus owning that machinery itself. It is a single loop
|
|
16
|
+
that can drive _any_ model — Claude through its API, or an open-weight model like
|
|
17
|
+
**Kimi**, **GLM**, or **DeepSeek** served over HuggingFace — through the same
|
|
18
|
+
tool-using, multi-step behavior. The model supplies intelligence; the harness supplies
|
|
19
|
+
the structure that makes that intelligence _agentic_.
|
|
20
|
+
|
|
21
|
+
The one-sentence version:
|
|
22
|
+
|
|
23
|
+
> **The harness repeatedly asks the model "what next?", does whatever the model asks
|
|
24
|
+
> for, feeds the result back, and repeats — until the model says it's finished.**
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## 2. The core loop
|
|
29
|
+
|
|
30
|
+
Everything centers on one cycle. Think of it as a conversation between the **harness**
|
|
31
|
+
(the orchestrator) and the **model** (the brain):
|
|
32
|
+
|
|
33
|
+
1. **Ask the model.** The harness sends the system prompt, the conversation so far, and
|
|
34
|
+
the list of available tools. It streams the model's reply back token by token, so the
|
|
35
|
+
user sees text appear live.
|
|
36
|
+
2. **Look at _why_ the model stopped.** Every model reply ends with a _stop reason_. Two
|
|
37
|
+
matter most:
|
|
38
|
+
- _"I want to use a tool."_ → the harness must run the tool(s) and continue.
|
|
39
|
+
- _"I'm done"_ (or any other reason) → the harness exits the loop and the turn ends.
|
|
40
|
+
3. **Run the requested tools.** If the model asked to use one or more tools, the harness
|
|
41
|
+
executes them, capturing each result (or error).
|
|
42
|
+
4. **Feed results back.** The tool results are added to the conversation as the next
|
|
43
|
+
message, exactly as if a user had pasted them in.
|
|
44
|
+
5. **Repeat from step 1.** The model now sees the results and decides the next move —
|
|
45
|
+
another tool, more tools, or a final answer.
|
|
46
|
+
|
|
47
|
+
This repeats until the model stops asking for tools. A single user message can therefore
|
|
48
|
+
trigger many internal round-trips (read a file → search it → edit it → confirm), all
|
|
49
|
+
invisible to the user except for the streamed narration.
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
user message
|
|
53
|
+
│
|
|
54
|
+
▼
|
|
55
|
+
┌──────────────────────────────────────────────┐
|
|
56
|
+
│ ask model → stream reply → stop reason? │◄────┐
|
|
57
|
+
└──────────────────────────────────────────────┘ │
|
|
58
|
+
│ "use tools" │ "done" │
|
|
59
|
+
▼ ▼ │
|
|
60
|
+
run tools finish turn │
|
|
61
|
+
│ │
|
|
62
|
+
└──── feed results back as a new message ──────────┘
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### A worked example
|
|
66
|
+
|
|
67
|
+
User asks: _"What PDF library does `read_file` use?"_
|
|
68
|
+
|
|
69
|
+
- **Round 1** — model: "I should read the tool handler." → asks for `read_file`.
|
|
70
|
+
Harness runs it, returns the file summary + chunk list.
|
|
71
|
+
- **Round 2** — model: "The relevant part is chunk 3." → asks for `read_content_chunk`.
|
|
72
|
+
Harness returns that chunk.
|
|
73
|
+
- **Round 3** — model now has what it needs → writes the final answer, stop reason "done".
|
|
74
|
+
Loop exits.
|
|
75
|
+
|
|
76
|
+
Three round-trips, one user-visible answer.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## 3. The provider abstraction
|
|
81
|
+
|
|
82
|
+
The loop never talks to a specific model vendor directly. It talks to a **provider** — a
|
|
83
|
+
thin adapter that knows how to (a) send a request and (b) stream back events. Swapping
|
|
84
|
+
the model is just swapping the provider:
|
|
85
|
+
|
|
86
|
+
| Provider | Used for | Notes |
|
|
87
|
+
| -------------------- | ------------------------------------------- | -------------------------------- |
|
|
88
|
+
| **Claude (default)** | `model: "claude"` threads | The historical path. |
|
|
89
|
+
| **HuggingFace** | Open-weight models (Kimi, GLM, DeepSeek, …) | OpenAI-compatible streaming API. |
|
|
90
|
+
|
|
91
|
+
Because the loop only depends on the abstract provider contract, **everything in this
|
|
92
|
+
primer applies identically to Kimi and to Claude.** The harness doesn't special-case the
|
|
93
|
+
model — it special-cases _behaviors_ (truncation, tool support), which is what the rest
|
|
94
|
+
of this document describes.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## 4. Tools: eager vs. deferred (the ToolSearch trick)
|
|
99
|
+
|
|
100
|
+
Models pay a token cost for every tool definition you show them. Cumulus exposes _many_
|
|
101
|
+
tools (file reading, content search, inter-agent messaging, email, scheduling, media
|
|
102
|
+
upload, …). Sending every full schema on every turn would waste thousands of tokens each
|
|
103
|
+
round-trip and crowd the context.
|
|
104
|
+
|
|
105
|
+
The harness solves this with a **two-tier tool system**:
|
|
106
|
+
|
|
107
|
+
- **Eager tools (~10).** The handful used constantly. Their full definitions are sent on
|
|
108
|
+
every turn so the model can call them instantly.
|
|
109
|
+
- **Deferred tools (everything else).** Only their _name and one-line description_ are
|
|
110
|
+
listed — not their full schema. They are real, callable tools; they just aren't
|
|
111
|
+
"loaded" yet.
|
|
112
|
+
|
|
113
|
+
When the model wants a deferred tool, it first calls a special tool named **ToolSearch**,
|
|
114
|
+
which returns the full schema for the tool(s) it named. Now the model can call the tool
|
|
115
|
+
for real.
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
deferred list (cheap): "- send_email: send an email via Resend"
|
|
119
|
+
│
|
|
120
|
+
▼ model calls ToolSearch("select:send_email")
|
|
121
|
+
full schema returned (expensive, but only when needed)
|
|
122
|
+
│
|
|
123
|
+
▼ model now calls send_email with correct arguments
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Two ways to query ToolSearch:
|
|
127
|
+
|
|
128
|
+
- **Exact lookup** — `select:tool_a,tool_b` returns those specific schemas.
|
|
129
|
+
- **Keyword search** — plain words fuzzy-match against tool names and descriptions.
|
|
130
|
+
|
|
131
|
+
**Crucial detail for OSS models:** the deferred schemas are _re-deferred every turn_ —
|
|
132
|
+
they are never permanently accumulated into the context. This keeps the per-turn token
|
|
133
|
+
count **flat** no matter how many tools exist or how long the task runs. For a model with
|
|
134
|
+
a smaller or pricier context window, this is what keeps long agentic sessions affordable.
|
|
135
|
+
|
|
136
|
+
> Practical note: a tool a model "doesn't see" may simply be deferred, not missing.
|
|
137
|
+
> The correct move is always to call ToolSearch first, never to assume a capability is
|
|
138
|
+
> unavailable.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## 5. Running tools: concurrent and bounded
|
|
143
|
+
|
|
144
|
+
When the model requests several tools in one turn, the harness runs them **all at once**
|
|
145
|
+
rather than one after another, then collects every result before replying. Independent
|
|
146
|
+
reads and searches finish in parallel instead of stacking up.
|
|
147
|
+
|
|
148
|
+
Each tool result is also **size-bounded**: anything enormous (over ~100 KB) is truncated
|
|
149
|
+
before being handed back to the model, so one giant output can't blow up the context.
|
|
150
|
+
Errors are captured too — a failing tool returns an error message flagged as an error,
|
|
151
|
+
which the model can read and react to, rather than crashing the turn.
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## 6. Surviving truncation (escalation & stitching)
|
|
156
|
+
|
|
157
|
+
Open-weight models, like all models, have an output-length ceiling per reply. When a
|
|
158
|
+
reply hits that ceiling it gets cut off mid-thought — the stop reason is _"ran out of
|
|
159
|
+
room."_ The harness handles this automatically, and the user ideally never notices. There
|
|
160
|
+
are two cases:
|
|
161
|
+
|
|
162
|
+
**Case A — cut off mid-sentence (plain text).**
|
|
163
|
+
The harness keeps the partial text, then sends a follow-up instruction: _"you were cut
|
|
164
|
+
off; continue exactly where you left off, here are your last ~200 characters, don't
|
|
165
|
+
repeat anything."_ Because the original text stream is still open, the continuation flows
|
|
166
|
+
in seamlessly. This is called **continuation stitching**.
|
|
167
|
+
|
|
168
|
+
**Case B — cut off mid-tool-call.**
|
|
169
|
+
If the cutoff landed inside a tool request, the tool arguments are now incomplete and
|
|
170
|
+
unusable. The harness **discards** that broken attempt entirely and **retries the whole
|
|
171
|
+
turn** with a larger output budget.
|
|
172
|
+
|
|
173
|
+
In both cases the budget grows along a fixed **escalation schedule** — roughly _16k →
|
|
174
|
+
32k → 65k tokens_ — giving the model progressively more room. Safety caps stop this from
|
|
175
|
+
running forever:
|
|
176
|
+
|
|
177
|
+
| Guardrail | Purpose |
|
|
178
|
+
| ----------------------------------- | ---------------------------------------------------- |
|
|
179
|
+
| Max continuations (~3) | Don't retry/continue endlessly. |
|
|
180
|
+
| Max cumulative output (~65k tokens) | Cap total tokens spent stitching one reply. |
|
|
181
|
+
| Per-model output ceiling | Never request more than the model actually supports. |
|
|
182
|
+
|
|
183
|
+
The escalation never exceeds what the specific model can produce — Kimi's ceiling is
|
|
184
|
+
respected just as Claude's is.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## 7. Pausing to ask the user a question
|
|
189
|
+
|
|
190
|
+
Sometimes the model genuinely needs a human decision mid-task (which approach? confirm
|
|
191
|
+
this destructive step?). For this there is a special tool, **AskUserQuestion**.
|
|
192
|
+
|
|
193
|
+
When the model calls it, the loop does something unusual: it **pauses and returns
|
|
194
|
+
control to the caller**, packaging up everything needed to resume later — the
|
|
195
|
+
conversation so far, which question is outstanding, and any _other_ tool results from the
|
|
196
|
+
same turn (those still run normally; only the question blocks progress).
|
|
197
|
+
|
|
198
|
+
The application shows the user the question (single choice, multi-select, or a small
|
|
199
|
+
carousel of questions, optionally with free-text). When the user answers, the harness is
|
|
200
|
+
called again with that answer slotted in as the tool's result, and the loop **resumes
|
|
201
|
+
exactly where it left off** — no work redone, no context lost.
|
|
202
|
+
|
|
203
|
+
So the lifecycle has three states, not two:
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
running ──► done
|
|
207
|
+
│
|
|
208
|
+
└──► needs_input ──(user answers)──► running ──► done
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## 8. Working step-by-step: "todo mode"
|
|
214
|
+
|
|
215
|
+
Cumulus encourages the model to track multi-step work in `<todo>` checklists. Left
|
|
216
|
+
unchecked, some models — especially eager open-weight ones — try to do _everything_ in
|
|
217
|
+
one giant reply, batching many tool calls and huge output into a single turn. That is
|
|
218
|
+
hard to follow and easy to truncate.
|
|
219
|
+
|
|
220
|
+
When the harness detects todo-tracking is active, it **caps the output budget per turn**
|
|
221
|
+
(to a few thousand tokens). This nudges the model to work in smaller increments: do a
|
|
222
|
+
step, report progress, update the checklist, take the next step. The effect is steadier,
|
|
223
|
+
more legible execution and fewer runaway responses.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## 9. What this means for an OSS model like Kimi
|
|
228
|
+
|
|
229
|
+
If you are running Kimi (or GLM, DeepSeek, etc.) inside this harness, here is the mental
|
|
230
|
+
model to operate by:
|
|
231
|
+
|
|
232
|
+
- **You are in a loop.** Your reply isn't the end — if you request a tool, you'll be
|
|
233
|
+
asked again with the result. Don't try to guess tool outputs; ask for the tool and wait.
|
|
234
|
+
- **Stop cleanly when finished.** End without requesting a tool and the loop ends. If you
|
|
235
|
+
keep requesting tools, the loop keeps going (up to a turn limit, ~30).
|
|
236
|
+
- **Use ToolSearch before exotic tools.** Only the ~10 core tools are pre-loaded. For
|
|
237
|
+
anything else, call ToolSearch by exact name or keyword first to get its schema, then
|
|
238
|
+
call it. A tool not shown is _deferred_, not absent.
|
|
239
|
+
- **Emit complete tool calls.** A tool request cut off halfway is thrown away and the
|
|
240
|
+
turn is retried — wasted effort. Prefer smaller, complete requests over one massive
|
|
241
|
+
batch.
|
|
242
|
+
- **Truncation is recoverable, but avoid it.** If you're cut off you'll be asked to
|
|
243
|
+
continue, but working in smaller steps (see todo mode) avoids the round-trips entirely.
|
|
244
|
+
- **Ask when genuinely unsure.** AskUserQuestion is a real escape hatch for decisions you
|
|
245
|
+
shouldn't make alone. It pauses the whole task safely.
|
|
246
|
+
- **Tool calling is the main reliability variable.** The single biggest difference
|
|
247
|
+
between a strong agentic run and a weak one on open models is disciplined, well-formed
|
|
248
|
+
tool use: correct tool names, valid arguments, one clear intent per call.
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## 10. The whole thing in one picture
|
|
253
|
+
|
|
254
|
+
```
|
|
255
|
+
┌─────────────────────────────────────────────┐
|
|
256
|
+
│ THE HARNESS │
|
|
257
|
+
│ │
|
|
258
|
+
user msg ─►│ build prompt + eager tools + deferred list │
|
|
259
|
+
│ │ │
|
|
260
|
+
│ ▼ │
|
|
261
|
+
│ ask model via PROVIDER │ ◄── Claude OR Kimi/GLM/…
|
|
262
|
+
│ (stream tokens out) │
|
|
263
|
+
│ │ │
|
|
264
|
+
│ why did it stop? │
|
|
265
|
+
│ ┌────────────┼─────────────┐ │
|
|
266
|
+
│ tool_use max_tokens done │
|
|
267
|
+
│ │ │ │ │
|
|
268
|
+
│ run tools escalate / finish turn │
|
|
269
|
+
│ (concurrent) stitch / │ │
|
|
270
|
+
│ │ retry exit │
|
|
271
|
+
│ ▼ │ │
|
|
272
|
+
│ feed results back ──┘ │
|
|
273
|
+
│ │ │
|
|
274
|
+
│ └──────────► loop again │
|
|
275
|
+
│ │
|
|
276
|
+
│ (AskUserQuestion → pause → resume later) │
|
|
277
|
+
└─────────────────────────────────────────────┘
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**In short:** the model is the brain; the harness is the nervous system. The harness
|
|
281
|
+
makes any capable model — Claude or open-weight — behave as a reliable, tool-using,
|
|
282
|
+
multi-step agent, while quietly handling the unglamorous realities of token budgets,
|
|
283
|
+
truncation, concurrency, and human-in-the-loop pauses.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# Conditional Thread Continuation — the Watcher-with-Deadline Pattern
|
|
2
|
+
|
|
3
|
+
**Status:** Adopted convention (Karl, 2026-07-26). No gateway code — this pattern composes
|
|
4
|
+
two primitives that already exist. A more robust gateway-native mechanism is sketched at
|
|
5
|
+
the end for when this outgrows convention.
|
|
6
|
+
|
|
7
|
+
## The problem
|
|
8
|
+
|
|
9
|
+
A thread kicks off something long-running with an uncertain finish time — a compile, a
|
|
10
|
+
test suite, a deploy, a download. The thread should continue **the moment the outcome is
|
|
11
|
+
known** (success _or_ failure), not after an arbitrary sleep, and not by burning a
|
|
12
|
+
scheduled AI turn every N minutes to poll ("did it finish yet?" cron turns dilute thread
|
|
13
|
+
history — the exact failure class tasks 088–092 fixed).
|
|
14
|
+
|
|
15
|
+
## The two primitives
|
|
16
|
+
|
|
17
|
+
### 1. `POST /api/agents/inject` — immediate continuation
|
|
18
|
+
|
|
19
|
+
Any process with a valid gateway API key can inject a message into a thread. The thread
|
|
20
|
+
starts a new turn immediately; if it's mid-stream, the message queues and delivers at
|
|
21
|
+
busy→idle (the task 100 unified queue). Exact shape (verified against
|
|
22
|
+
`server.ts handleAgentInject`):
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
curl -s -X POST http://localhost:8090/api/agents/inject \
|
|
26
|
+
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
|
|
27
|
+
-d '{"targets": "THREAD_NAME", "sender": "build-watcher", "message": "..."}'
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- `targets` (string or array) and `sender` and `message` are required. There is no
|
|
31
|
+
`target`/`from` — those names will 400.
|
|
32
|
+
- Auth: `X-API-Key: <key>` or `Authorization: Bearer <key>`.
|
|
33
|
+
- On thundercat, a key is readable from the gateway config:
|
|
34
|
+
`KEY=$(jq -r '.apiKeys[0]' "${CUMULUS_DIR:-$HOME/.cumulus}/gateway.config.json")`
|
|
35
|
+
Read it through `$CUMULUS_DIR`, never as a literal `~/.cumulus` — a hard-coded path
|
|
36
|
+
points a sandboxed run (tests, a second gateway) straight at the production gateway
|
|
37
|
+
with the production admin key (task 111).
|
|
38
|
+
|
|
39
|
+
### 2. `schedule_trigger` MCP tool — the time-based floor
|
|
40
|
+
|
|
41
|
+
Available in every thread (gateway-agents MCP). `{id, trigger: "once", at: "<ISO datetime>",
|
|
42
|
+
message}` fires one injection at a fixed time then auto-removes; `trigger: "cron"` +
|
|
43
|
+
`cron` recurs (1-minute resolution). Persisted in thread config — **survives gateway
|
|
44
|
+
restarts**, which the watcher does not. Companions: `list_schedules`, `cancel_schedule`.
|
|
45
|
+
|
|
46
|
+
## The pattern
|
|
47
|
+
|
|
48
|
+
**Watcher for immediacy, one-shot schedule for the guarantee.** The condition-watcher
|
|
49
|
+
doesn't need to live in the gateway: the thread's Claude subprocess runs on the same
|
|
50
|
+
machine and can leave a detached process behind.
|
|
51
|
+
|
|
52
|
+
### Step 1 — detach the job with a watcher wrapper
|
|
53
|
+
|
|
54
|
+
For a _process-exit_ condition (the common case — build/test/deploy), the wrapper simply
|
|
55
|
+
runs the job and fires when it exits, success or error:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
KEY=$(jq -r '.apiKeys[0]' "${CUMULUS_DIR:-$HOME/.cumulus}/gateway.config.json")
|
|
59
|
+
nohup sh -c '
|
|
60
|
+
npm run build > /tmp/build.log 2>&1
|
|
61
|
+
rc=$?
|
|
62
|
+
curl -s -X POST http://localhost:8090/api/agents/inject \
|
|
63
|
+
-H "X-API-Key: '"$KEY"'" -H "Content-Type: application/json" \
|
|
64
|
+
-d "{\"targets\":\"cumulus\",\"sender\":\"build-watcher\",
|
|
65
|
+
\"message\":\"[watcher] Build finished, exit $rc. Log: /tmp/build.log. First: cancel_schedule(\\\"build-deadline\\\").\"}"
|
|
66
|
+
' >/dev/null 2>&1 &
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
For a condition that isn't a process exit (file appears, port answers, log line shows up),
|
|
70
|
+
put an `until` loop before the curl:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
until <check-command>; do sleep 5; done # e.g. curl -sf http://localhost:3000/health
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Guidelines:
|
|
77
|
+
|
|
78
|
+
- **`>/dev/null 2>&1 &`** — redirecting _both_ streams is what makes the watcher survive
|
|
79
|
+
the Claude turn ending, and it is the only part that does. The turn's subprocess exits
|
|
80
|
+
at turn end; anything still holding its inherited stdout/stderr pipes is killed by
|
|
81
|
+
SIGPIPE (exit 141) on the next write. `nohup` alone does not save it (it ignores SIGHUP,
|
|
82
|
+
the wrong signal, and only auto-redirects when stdout is a terminal — here it is a pipe),
|
|
83
|
+
and neither does `setsid` (nothing is signalling the process group). Prefer a real log
|
|
84
|
+
file over `/dev/null` when the woken turn will need to diagnose. Surviving the turn is
|
|
85
|
+
all this buys — the watcher is still unsupervised and does NOT survive a machine reboot;
|
|
86
|
+
that's what step 2 is for.
|
|
87
|
+
- **Always report failure too.** The wrapper fires on _outcome_, not on _success_ —
|
|
88
|
+
include the exit code and a log path so the woken turn can diagnose without re-running.
|
|
89
|
+
- **Tell the woken turn to cancel the deadline** (put it in the message, as above) so the
|
|
90
|
+
fallback never double-fires.
|
|
91
|
+
- Name the `sender` after the condition (`build-watcher`, `deploy-watcher`) — it prefixes
|
|
92
|
+
the injected message and reads clearly in history.
|
|
93
|
+
- **Do not reply to the watcher.** Injected messages carry a "Reply using
|
|
94
|
+
`send_to_agent(...)`" footer intended for agent-to-agent traffic. A watcher is a script,
|
|
95
|
+
not an agent, so replying to it mints an empty thread named after the sender and burns a
|
|
96
|
+
turn in it. Measured while verifying task 109: a woken turn replied to `job-watcher` and
|
|
97
|
+
a `job-watcher` thread appeared on disk. Act on the report instead.
|
|
98
|
+
|
|
99
|
+
### Step 2 — set the deadline fallback
|
|
100
|
+
|
|
101
|
+
In the same turn that spawns the watcher, set a one-shot schedule as the floor:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
schedule_trigger({
|
|
105
|
+
id: "build-deadline",
|
|
106
|
+
trigger: "once",
|
|
107
|
+
at: "<now + generous timeout, ISO 8601>",
|
|
108
|
+
message: "[deadline] The build watcher never reported back. Check /tmp/build.log and whether the process is still running."
|
|
109
|
+
})
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Why this matters: the watcher is an unsupervised background process. If the machine
|
|
113
|
+
reboots, the gateway is restarted mid-job, or the script dies, the thread would otherwise
|
|
114
|
+
wait forever. The schedule persists in thread config, so the thread is _guaranteed_ to
|
|
115
|
+
wake — worst case at the deadline, best case the instant the condition fires.
|
|
116
|
+
|
|
117
|
+
### Step 3 — on wake, cancel the other leg
|
|
118
|
+
|
|
119
|
+
- Watcher fired → first action: `cancel_schedule("build-deadline")`.
|
|
120
|
+
- Deadline fired → check the log/process manually; kill any orphaned watcher
|
|
121
|
+
(`pkill -f build-watcher-marker` if you tagged the command line).
|
|
122
|
+
|
|
123
|
+
### Anti-pattern: cron polling turns
|
|
124
|
+
|
|
125
|
+
Do **not** use `schedule_trigger` with `trigger: "cron"` to poll a condition every N
|
|
126
|
+
minutes. Each poll burns a full AI turn and appends "checked, nothing yet" noise to
|
|
127
|
+
thread history, degrading retrieval quality for every future turn. Cron is for genuinely
|
|
128
|
+
periodic work (weekly reports, drip follow-ups), not condition-waiting.
|
|
129
|
+
|
|
130
|
+
## Known limitations of this pattern
|
|
131
|
+
|
|
132
|
+
These are accepted trade-offs of the convention, and the reason a robust version may be
|
|
133
|
+
worth building later:
|
|
134
|
+
|
|
135
|
+
1. **No supervision** — the watcher is a bare background process; nothing restarts it.
|
|
136
|
+
The deadline fallback bounds the damage but adds latency in the failure case.
|
|
137
|
+
2. **No visibility** — there is no `list_watches`; a pending watcher is invisible to the
|
|
138
|
+
thread, the user, and the dashboard. You can't enumerate or cancel what you can't see.
|
|
139
|
+
3. **Key in the script** — the watcher needs a gateway API key in its environment/command
|
|
140
|
+
line. On thundercat (single-user box) this is acceptable; it would not be under
|
|
141
|
+
namespace-scoped multi-tenant use (task 097 P7).
|
|
142
|
+
4. **Reboot amnesia** — schedules survive restarts, watchers don't. After a reboot only
|
|
143
|
+
the deadline leg remains.
|
|
144
|
+
|
|
145
|
+
## Later: the robust version (`await_condition`, gateway-native)
|
|
146
|
+
|
|
147
|
+
If condition-waiting becomes a recurring pattern, the honest fix is a first-class
|
|
148
|
+
gateway watcher living beside `scheduler.ts` (per Rule #2 it needs a task doc before any
|
|
149
|
+
implementation). Sketch:
|
|
150
|
+
|
|
151
|
+
- **Tool:** `await_condition({id, check, interval, timeout, message})` — the gateway
|
|
152
|
+
polls `check` (a shell predicate) every `interval` seconds and injects `message` the
|
|
153
|
+
moment it exits 0. `timeout` fires a "condition never came true" injection instead of
|
|
154
|
+
waiting forever.
|
|
155
|
+
- **Persistence:** watches stored in thread config like schedules → survive gateway
|
|
156
|
+
restarts and machine reboots (poll loop resumes on startup). This alone removes
|
|
157
|
+
limitations 1, 2, and 4.
|
|
158
|
+
- **Visibility:** `list_watches` / `cancel_watch` companions, same shape as
|
|
159
|
+
`list_schedules` / `cancel_schedule`; dashboard can surface pending watches.
|
|
160
|
+
- **Reuse:** fires through the same `sendMessage` path as the scheduler, so queueing,
|
|
161
|
+
busy→idle drain, and history semantics are identical to today's injections.
|
|
162
|
+
- **Design question to settle in the task doc:** the predicate is the gateway executing a
|
|
163
|
+
configured shell command. Fine for admin threads on a single-user box; under P7
|
|
164
|
+
namespace scoping it needs an allowlist or per-namespace opt-in before scoped keys can
|
|
165
|
+
create watches.
|
|
166
|
+
|
|
167
|
+
Until then: watcher + deadline, as above.
|