2026-10-04 02:55:51 +00:00
# JOB-SPEC.md: Hosted Job Scheduler and Distributor
2026-10-05 15:58:37 +00:00
> **Box is the main surface.** All operator work goes through Box (box.muse-dev.online). The web UI, `box` CLI, and agents share the same API endpoints. No UI-only powers.
2026-10-04 02:55:51 +00:00
## Overview
A hosted system on bl that processes and distributes jobs to agents via DM.
Cron jobs and system scripts inject prompts/jobs; agents execute them.
Loops run as loops on the server, not in agent heads.
**Principle: ** Limit agency to get smarter. The server decides * what * and * when * ;
agents decide * how * . Deterministic, auditable, debuggable.
## Architecture
```
┌──────────────────────────────────────────────────┐
│ Hosted on bl (systemd timers + scripts) │
│ │
│ ┌────────────┐ ┌─────────────┐ │
│ │ Scheduler │→ │ Dispatcher │→ dm.py send │
│ │ (cron) │ │ (render+log)│ │
│ └────────────┘ └─────────────┘ │
│ ↑ ↓ │
│ │ ┌─────────────┐ │
│ └─────────│ Collector │← DM [RESULT] │
│ │ (log+chain) │ │
│ └─────────────┘ │
└──────────────────────────────────────────────────┘
```
## Components
### 1. Job Definition (YAML)
Location: `/home/super/Projects/NetVM/jobs/<name>.yaml`
``` yaml
name : board-watch
description : "Check board for new posts every 5 minutes"
schedule : "*/5 * * * *" # cron format
agent : muse # which agent executes
sidechat :
create : false # use main chat
# OR:
# create: true
# name_template: "job-{name}-{date}"
# reuse_pattern: "job-{name}-*" # for chaining
prompt_template : |
Check the board for posts since {last_run}.
Summarize new items in 3 bullet points.
Reply with [RESULT {job_id}] followed by your summary.
timeout : 300 # seconds before marking failed
on_failure : retry # retry | alert | ignore
chain_next : null # job to trigger after success
```
**Fields: **
- `name` : Unique job identifier (used in logs, sidechat names)
- `schedule` : Cron expression (systemd timer or cron)
- `agent` : Target agent (`muse` , `pip` , `646` , `opm` )
- `sidechat` : Side chat configuration (see below)
- `prompt_template` : Jinja-style template with `{variables}`
- `timeout` : Max seconds to wait for result
- `on_failure` : What to do on timeout/failure
- `chain_next` : Next job to trigger (for chains)
### 2. Scheduler
Uses systemd timers (preferred) or cron. Each job gets a timer unit.
**Systemd timer example: **
``` ini
# /etc/systemd/user/job-board-watch.timer
[Unit]
Description = Run board-watch job every 5 minutes
[Timer]
OnCalendar = *:0/5
Persistent = true
[Install]
WantedBy = timers.target
```
**Service: **
``` ini
# /etc/systemd/user/job-board-watch.service
[Unit]
Description = Dispatch board-watch job
[Service]
Type = oneshot
ExecStart = /home/super/Projects/NetVM/bin/job-dispatch.sh board-watch
```
### 3. Dispatcher (`bin/job-dispatch.sh`)
Responsibilities:
1. Load job YAML
2. Render prompt template with variables (`{job_id}` , `{last_run}` , `{date}` , etc.)
3. Create side chat if specified
4. Send DM via `dm.py` :
- Format: `[JOB {job_id}] {rendered_prompt}`
- Use `--raw` if prompt is pre-signed
5. Log to `job-log.jsonl` : `{job_id, job_name, agent, sidechat, sent_at}`
6. If `chain_next` , schedule the next job (or trigger immediately on result)
**Job ID format: ** `{name}-{YYYYMMDD-HHMMSS}-{short_uuid}`
Example: `board-watch-20261004-023000-a1b2c3d4`
### 4. Agent Job Handler (Convention)
Agents MUST recognize job DMs and respond in format.
**Job DM format: **
```
[JOB board-watch-20261004-023000-a1b2c3d4] Check the board for posts
since 2026-10-04T02:25:00Z. Summarize new items in 3 bullet points.
Reply with [RESULT board-watch-20261004-023000-a1b2c3d4] followed by
your summary.
```
**Agent responsibilities: **
1. Parse `[JOB {job_id}]` from DM
2. Execute the prompt
3. If `sidechat` specified, work in that side chat
4. Reply via DM with: `[RESULT {job_id}] {result_text}`
5. If unable, reply: `[RESULT {job_id}] FAILED: {reason}`
**Teaching: ** New agents get `JOB-HANDLER.md` with examples. The format is
simple enough to learn from 2-3 examples.
### 5. Collector
Watches for `[RESULT {job_id}]` in DM logs or via `dm.py log` .
**Responsibilities: **
1. Parse result DMs
2. Log to `job-log.jsonl` : `{job_id, completed_at, success, result_preview}`
3. If `chain_next` specified and result was success, trigger next job
4. On timeout (no result within `timeout` ), mark failed, apply `on_failure`
**Timeout handling: ** A background sweeper checks for jobs with `sent_at`
older than `timeout` and no result. Marks them failed.
## Side Chat Integration
### Job → Side Chat Mapping
Jobs can specify side chat behavior:
**Option A: No side chat ** (use main chat)
``` yaml
sidechat :
create : false
```
**Option B: Create new side chat per run **
``` yaml
sidechat :
create : true
name_template : "job-{name}-{date}" # e.g., "job-board-watch-20261004"
```
**Option C: Reuse side chat (for chains) **
``` yaml
sidechat :
create : false
reuse_pattern : "job-{name}-*" # find most recent
# OR:
# reuse_name: "{prev_job_sidechat}" # from chain
```
### Side Chat as Job Workspace
When `create: true` :
1. Dispatcher calls `dm.py` sidechat create (via `muse-chat-api.py` )
2. Gets the side chat name/ID
3. Includes it in the job DM: "Work in side chat 'job-board-watch-20261004'"
4. Logs the mapping: `{job_id → sidechat_name}`
5. Agent does all work in that side chat (full context, isolated)
**Benefits: **
- Each job run has isolated context
- The side chat IS the audit log
- Chains: Job B can continue in Job A's side chat
- No cross-talk between concurrent jobs
## Logging
### `job-log.jsonl` (on bl)
Append-only, one JSON per line:
``` json
{ "ts" : "2026-10-04T02:30:00Z" , "type" : "job_sent" , "job_id" : "..." , "job_name" : "board-watch" , "agent" : "muse" , "sidechat" : null }
{ "ts" : "2026-10-04T02:32:15Z" , "type" : "job_result" , "job_id" : "..." , "success" : true , "duration_s" : 135 }
{ "ts" : "2026-10-04T02:35:00Z" , "type" : "job_timeout" , "job_id" : "..." , "job_name" : "board-watch" }
```
### `sidechat-log.jsonl` (on bl)
From `sidechat_manager.py` :
``` json
{ "ts" : "..." , "agent" : "muse" , "op" : "create" , "details" : "job-board-watch-20261004" }
{ "ts" : "..." , "agent" : "muse" , "op" : "navigate" , "details" : "main" }
```
## Rate Limiting
Use the shared `bin/rate_limiter.py` module:
``` python
from rate_limiter import rate_limit_wait
rate_limit_wait ( agent ) # blocks if too frequent
```
Default: 1 DM per 3s per agent, burst 5, max 20/min. Prevents accidental
spam if a job misfires in a loop.
## Security
- Job definitions are in git (auditable)
- Only operators can create/edit jobs (file permissions)
- Agents cannot create jobs (they execute, not schedule)
- DMs are logged (dm-log.jsonl) for audit
- Side chats are per-job, not shared across trust boundaries
2026-10-06 08:20:14 +00:00
## Completion Enforcement
Sending a job DM does not complete work. Four mechanisms close the loop on bl:
### 1. `on_no_result` fallback (server-side guarantee)
A job may declare a fallback effect that runs when its followup reaches
terminal expiry with no agent result (`bin/followup-sweeper.py` ):
``` json
"on_no_result" : { "op" : "swarm.spawn" , "args" : { "count" : 2 , "task" : "..." , "label" : "..." } }
"on_no_result" : { "job" : "<another-job-name>" }
```
- `op` form: validated + built through the `exec-constrained` op registry
(same validators the daemon uses), then executed as a subprocess.
- `job` form: dispatches the named job via `job-dispatch.py` with
`CHAIN_PREV_*` timeout context.
- Outcomes log as `fallback_executed` / `fallback_failed` in `job-log.jsonl`
and stamp `rec["fallback"]` on the followup record. Failures never break
the sweep. Jobs without the key behave exactly as before.
- Firing paths (either; never both): the sweeper terminal branch
(`nudges_sent >= nudges_allowed` , expired) and `gravity.py:remediate_breaks`
(same terminal condition, runs on `loop-remediator.timer` ~every 15m).
`fallback_due()` dedupes: fires once per record, retries a failed attempt
after 1h. Gravity also revives the local sweep loop (it previously had no
`__main__` , so the timer was a no-op) with a 300s re-arm budget (was 10s,
which strangled every sweep mid-first-send — median nudge send is ~10s).
- Split-brain note: the VM board sweeper (`box-request-sweeper.timer` ) sends
the live `[NUDGE <id>]` DMs from remote `dm_followup` requests; the local
sweeper sends `[nudge N/M]` from `followups.json` . Both fire per deadline
until a reply resolves both sides. Accept the duplicate nudge as the cost
of a guaranteed local path; cross-system dedup is future work.
- Seeded on: `autonomy-pulse-646` , `autonomy-pulse-pip` , `autonomy-pulse-opm`
(each spawns 2 standing-work subagents if the agent naps through the pulse).
- Race note: set the job's followup timeout longer than the expected work
time, or a slow-but-working agent can double-fire alongside the fallback.
### 2. NACK for directive-less replies (existing, sharpened)
`response-harvester.py:maybe_nudge_untagged_sidechat` already rejects
conversational replies in job-backed threads (1-turn strict nudge, then a
5-minute escalation timer to opm; tracked in
`conversation-nudge-tracker.json` ). Two refinements:
- Acted-variant: when the agent emitted directives but never closed with
`[RESULT]` , the nudge acknowledges the action and demands the close
instead of crying "commentary rejected".
- Emit-model wording: envelope (`prompt_envelope._response_rule` ), tool
hint, and strict nudge now state plainly that agents EMIT `[TOOL]` /
`[DM]` lines verbatim and the Box runtime on bl executes them — this
works from containers with no box CLI or tmux socket. (Root cause of the
2026-10-06 zero-`tool_exec` stretch: agents believed they had to execute
tools locally and declined for lack of a "container equivalent".)
### 3. Proof-of-result followups
A success `[RESULT]` with no checkable artifact (swarm/timer IDs, UUIDs,
paths, `n/m` completion counts — see `result_has_evidence` ) triggers a
one-shot `followup.create` (+30m, same thread) asking for the evidence, and
logs `proof_requested` . One per (thread, job). Failures and declines skip
proof (they already chain via `on_failure` ).
### 4. Completion auditor (15-minute timer)
`bin/completion-audit.py` via `systemd/completion-audit.timer`
(`OnCalendar=*:4/15` , installed to `/etc/systemd/system` ):
- Computes the funnel per job family over `--hours` (default 24) from
`job-log.jsonl` : sent / dispatched / `tool_exec` / results, plus
`fallback_*` , `proof_requested` , and `job_failed` counts.
- Adds point-in-time swarm slot drain (flags running slots idle >90m) and
followup backlog (pending / overdue / escalated).
- Writes `logs/completion-audit-<ts>.json` + `logs/completion-audit-latest.json` .
- Posts the digest to `opm` / `heartbeat` only when DEGRADED
(silent families, failures, tool errors, stale slots, overdue/escalated
followups); when green, posts a heartbeat at most every 6h
(`logs/completion-audit-state.json` ). Silent-when-healthy otherwise.
- Read-only except the digest DM and its own log/state files.
2026-10-04 02:55:51 +00:00
## Future Expansions
1. **Conditional jobs ** : Run Job B only if Job A succeeds with specific output
2. **Parallel jobs ** : Fan-out to multiple agents, collect all results
3. **Human approval ** : Certain jobs require human sign-off before dispatch
4. **Web UI ** : View job status, logs, side chats from box.muse-dev.online
5. **Agent-proposed jobs ** : Agents can suggest jobs via `[PROPOSE]` DM, human approves
## Status
Spec v0.1. Owner: operator-main. Sanctioned by the human 2026-10-04.
Implementation: dispatcher script + example jobs pending.