Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 52 additions & 95 deletions docs/code/automated-task-processing.md

Large diffs are not rendered by default.

20 changes: 18 additions & 2 deletions docs/code/worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: "Worker Daemon"
description: "Run devintern as a single long-running worker that reacts to PR reviews and tracker changes"
section: "Server Automation"
order: 0
dateModified: 2026-08-26
dateModified: 2026-08-27
---

# Worker Daemon
Expand Down Expand Up @@ -165,6 +165,22 @@ The worker log is the diagnostic. Look for `[poll:<tracker>]` (for Jira, `[poll:
- `have no update stamp from the tracker` — search results are missing `updated`, so the worker cannot tell versions apart and will not retry after the first attempt. Restarting the worker does not help; a one-off `devintern KEY` still runs the ticket by hand.
- No tracker pickup/skip lines at all — nothing has changed since the last cursor in `.devintern-code/queue.db`. A ticket last edited before that cursor is not re-evaluated until something on the tracker updates.

## Working windows (quiet hours)

The drain of ready tasks can be limited to wall-clock windows — nights only is the classic case — using `[worker.schedule]` in `workspace.toml`:

```toml
[worker.schedule]
active = ["22:00-06:00"] # pickup allowed only inside these daily windows
blocked = [] # subtract from active windows; wins on conflict
timezone = "" # optional IANA name; blank = machine local time
catch_up_missed = true # one catch-up drain if a whole window elapsed unused
```

Windows are wall-clock per day, may cross midnight (`start` greater than `end`), union when multiple are set, and resolve overlaps toward staying quiet (`blocked` always wins). Only **new-task pickup** pauses: review replies, @mentions, recurring automations, and relay events run normally, and any task already picked up finishes even after its window closes.

Timezone and DST details, missed-window catch-up, and status surfaces (startup banner, one-log-line-per-flip, dashboard strip) are described in [Working windows](./automated-task-processing.md#working-windows-quiet-hours). To force an immediate drain without touching the schedule, run `devintern worker run-now`.

## Options

The daemon itself takes almost no flags. Durable settings live in `workspace.toml`:
Expand Down Expand Up @@ -252,7 +268,7 @@ Every run is recorded stage by stage in the local database. The worker serves th

## Running as a service

The worker runs identically on a laptop, VM, or container. `devintern worker init` can write a user-level systemd unit on Linux or a launchd agent on macOS into the workspace home, then prints explicit installation commands. It never installs or starts the service without you running those commands. Running `devintern worker` in a terminal remains fully supported. For pm2 and tunnel setups (advanced webhook mode), see the [GitHub Integration guide](./github-integration.md). If you need a wall-clock window instead of a resident process, see [Night-only CLI runs](./automated-task-processing.md#night-only-cli-runs).
The worker runs identically on a laptop, VM, or container. `devintern worker init` can write a user-level systemd unit on Linux or a launchd agent on macOS into the workspace home, then prints explicit installation commands. It never installs or starts the service without you running those commands. Running `devintern worker` in a terminal remains fully supported. For pm2 and tunnel setups (advanced webhook mode), see the [GitHub Integration guide](./github-integration.md). If you want the resident daemon idle during parts of the day, configure [working windows (quiet hours)](#working-windows-quiet-hours) instead of wrapping the CLI in cron.

## License

Expand Down
11 changes: 10 additions & 1 deletion docs/code/workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ repo = "frontend"
project = "WEB"
labels = ["frontend"]

[worker.schedule]
active = ["22:00-06:00"] # optional quiet hours: drain new tasks only at night
blocked = [] # subtract from active windows (conflicts resolve to quiet)
timezone = "" # blank = worker machine's local time
catch_up_missed = true

[[automations]]
id = "backend-maintenance"
enabled = true
Expand All @@ -84,6 +90,7 @@ prompt = "Review the frontend and clean up one source of recurring noise."
- `pr_labels` applies labels to every PR the fleet creates (GitHub only). A repo's `pr_labels` overrides `[defaults].pr_labels`. Outside a workspace, single-repo users get the same behavior by setting `PR_LABELS` (comma-separated) in `.devintern-code/.env`.
- Repo names must be unique and filesystem-safe; they become directory names under `repos/` and `worktrees/`.
- Rule criteria combine with AND; list values (`components`, `labels`) match when the task carries any of them. Comparisons are case-insensitive. `project` matches the task key prefix for `PROJ-123` style keys (Jira, Linear); trackers with numeric or opaque ids route via labels or components.
- `[worker.schedule]` gates only new-task pickup: multiple windows union, windows may cross midnight, `blocked` wins on overlap, and a missed whole window triggers one catch-up drain at startup. Timezone/DST semantics and `devintern worker run-now` are covered in [Automated Task Processing → Working windows](./automated-task-processing.md#working-windows-quiet-hours).
- `[[automations]]` uses the same schema as single-repo `.devintern-code/automations.toml`. An entry must name `repo` when the workspace has more than one repository. See [Worker Daemon → Recurring automations](./worker.md#recurring-automations) for prompt-writing guidance and schedule semantics.

### Automatic conflict resolution: `auto` vs `scheduled` vs `disabled`
Expand Down Expand Up @@ -149,7 +156,9 @@ devintern worker # auto-detects ~/.devintern/workspace.toml
devintern worker --workspace /path/to/workspace.toml
```

The fleet query comes from `[defaults].task_query`. A workspace with automations can omit the query and run as an automation-only worker. Poll interval, per-task flags, and the embedded dashboard are also set in `workspace.toml` (`poll_interval`, `worker_task_args`, `[workspace].dashboard` / `dashboard_port`). Direct webhooks are an advanced repo-local service: run `devintern webhook serve` from that repository as a separate process. Workspace and automation configuration is loaded at startup; restart the worker after editing it. Schedule state and leases for automations live in the central workspace database.
The fleet query comes from `[defaults].task_query`. A workspace with automations can omit the query and run as an automation-only worker. Poll interval, per-task flags, and the embedded dashboard are also set in `workspace.toml` (`poll_interval`, `worker_task_args`, `[worker.schedule]` quiet hours, `[workspace].dashboard` / `dashboard_port`). Direct webhooks are an advanced repo-local service: run `devintern webhook serve` from that repository as a separate process. All configuration — schedule included — is loaded at startup; restart the worker after editing it. Schedule state, leases, and the last-drain timestamp for catch-up live in the central workspace database.

While the daemon is running you can request one immediate drain (for example while quiet hours are closed) with `devintern worker run-now`; see [Working windows](./automated-task-processing.md#working-windows-quiet-hours).

`devintern worker init` can generate a user-level systemd unit on Linux or launchd agent on macOS. One service runs the whole workspace. For a hand-written Linux unit:

Expand Down
18 changes: 7 additions & 11 deletions packages/code/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,20 +437,16 @@ devintern worker init
devintern worker
```

See the [Worker Daemon guide](https://devintern.com/docs/code/worker) and [Automated Task Processing](https://devintern.com/docs/code/automated-task-processing). Cron of the CLI remains only as a gap filler for a wall-clock window (for example only at night) until the worker has quiet hours, and for `--estimate` schedules.
See the [Worker Daemon guide](https://devintern.com/docs/code/worker) and [Automated Task Processing](https://devintern.com/docs/code/automated-task-processing). The worker natively supports **working windows (quiet hours)**, so "only at night" no longer needs cron — set `[worker.schedule]` in `workspace.toml`:

```bash
# Night-only drain, if you are not running the worker
0 22 * * * cd /path/to/your/project && devintern --query 'statusCategory = "To Do" AND sprint in openSprints() AND labels IN (Intern) ORDER BY created DESC' --max-turns 500 --create-pr >> /tmp/devintern-cron.log 2>&1
```toml
[worker.schedule]
active = ["22:00-06:00"] # drain new tasks only during these local-time windows
timezone = "" # optional IANA name; blank = machine local time
catch_up_missed = true # one catch-up drain at startup after a fully missed window
```

**Notes if you keep a timer:**

- Always change to your project directory (`cd /path/to/your/project`) so the correct `.devintern-code/.env` is loaded
- Use absolute paths or ensure PATH includes `devintern` and the agent binary
- Redirect output to a log file (`>> /tmp/devintern-cron.log 2>&1`)
- For Jira, use `ORDER BY created DESC` to process newest tasks first
- Test your query manually before scheduling
Outside the window nothing is killed mid-run — the in-flight task completes and no new tracker tasks start. Force one immediate drain with `devintern worker run-now`. The only CLI run still worth a timer is story-point estimation (`--estimate`).

## Troubleshooting

Expand Down
3 changes: 3 additions & 0 deletions packages/code/src/dashboard-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export interface DashboardServerOptions {
dbPath?: string;
/** Project root used to locate the worker lock file. */
workingDir?: string;
/** Live working-window snapshot provider (embedded dashboard). */
scheduleSnapshot?: () => import("./lib/schedule").ScheduleSnapshot | null;
/**
* Retry execution mode (default `spawn`). The workspace worker passes
* `schedule` so dashboard retries are drained through the fleet pipeline;
Expand Down Expand Up @@ -103,6 +105,7 @@ export function startDashboardServer(
const data = new DashboardData({
dbPath: options.dbPath,
workingDir: options.workingDir,
scheduleSnapshot: options.scheduleSnapshot,
retryMode: options.retryMode,
logDirs: options.logDirs,
});
Expand Down
37 changes: 36 additions & 1 deletion packages/code/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,40 @@ if (process.argv[2] === "init") {
process.exit(exitCode);
}

// `devintern worker run-now` — ask a running workspace worker for one
// immediate drain, bypassing working windows without editing them.
if (process.argv[3] === "run-now") {
const args = process.argv.slice(4);
let workspacePath: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--workspace" && args[i + 1] && !args[i + 1]?.startsWith("-")) {
workspacePath = args[i + 1];
i++;
} else if (arg === "--help" || arg === "-h") {
console.log("Usage: devintern worker run-now [--workspace <path>]");
console.log("");
console.log("Ask the running workspace worker to drain ready tasks now,");
console.log("ignoring working windows (quiet hours) for this one pass.");
console.log("The worker picks up the request on its next poll interval");
console.log("(default 60s) and deletes the marker once served.");
process.exit(0);
}
}
const { resolveWorkspaceDir, workspaceConfigPath, workspaceRunNowPath } =
await import("./lib/workspace/paths");
const selectedDir = workspacePath ? dirname(resolve(workspacePath)) : resolveWorkspaceDir();
if (!existsSync(workspaceConfigPath(selectedDir))) {
console.error(`❌ No workspace.toml at ${workspaceConfigPath(selectedDir)}.`);
process.exit(1);
}
writeFileSync(workspaceRunNowPath(selectedDir), "");
console.log(`✅ Run-now requested for ${workspaceConfigPath(selectedDir)}`);
console.log(` Marker: ${workspaceRunNowPath(selectedDir)}`);
console.log(" The worker drains within one poll interval and removes the marker.");
process.exit(0);
}

const args = process.argv.slice(3);

if (args[0] === "init") {
Expand Down Expand Up @@ -694,7 +728,7 @@ if (process.argv[2] === "init") {
} else if (arg === "-v" || arg === "--verbose") {
verbose = true;
} else if (arg === "--help" || arg === "-h") {
console.log("Usage: devintern worker [init] [options]");
console.log("Usage: devintern worker [init|run-now] [options]");
console.log(" devintern worker connect [github|status] [--repo owner/name]");
console.log("");
console.log("Run the devintern worker daemon. The worker acquires events (reviews on");
Expand All @@ -710,6 +744,7 @@ if (process.argv[2] === "init") {
" init Guided unattended setup: tracker, workspace, ready-tasks",
);
console.log(" query (live dry run), and license check");
console.log(" run-now One immediate drain, ignoring working windows");
console.log("");
console.log("Options:");
console.log(" --workspace <path> Use this workspace.toml (default: ~/.devintern/");
Expand Down
19 changes: 19 additions & 0 deletions packages/code/src/lib/dashboard-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import { LockManager } from "./lock-manager";
import { RunStore } from "./run-recorder";
import type { RunOrigin, RunRecord, RunStageRecord, RunStats, RunStatus } from "./run-recorder";
import type { ScheduleSnapshot } from "./schedule";
import {
isRunRetriable,
resolveDashboardActor,
Expand Down Expand Up @@ -65,6 +66,12 @@ export interface DashboardDataOptions {
dbPath?: string;
/** Project root used to locate the worker lock file. */
workingDir?: string;
/**
/**
* Live working-window snapshot from the worker process (embedded
* dashboard only; standalone servers return null).
*/
scheduleSnapshot?: () => ScheduleSnapshot | null;
/**
* How a retry is executed. `spawn` (default) starts a detached CLI
* subprocess — correct for a standalone `devintern dashboard` running
Expand Down Expand Up @@ -119,6 +126,7 @@ export class DashboardData {
private readonly logDirs: string[];
private readonly maxLogBytesPerFile: number | undefined;
private stores: Stores | null = null;
private readonly scheduleSnapshot: () => ScheduleSnapshot | null;
/** Lazy read-write connection for the retry audit trail. */
private retryAuditStore: RunRetryAuditStore | null = null;
/** Lazy read-write connection for scheduled retries (schedule mode). */
Expand All @@ -134,6 +142,7 @@ export class DashboardData {
constructor(options: DashboardDataOptions = {}) {
this.dbPath = options.dbPath ?? resolveQueueDbPath();
this.workingDir = options.workingDir ?? process.cwd();
this.scheduleSnapshot = options.scheduleSnapshot ?? (() => null);
this.retryMode = options.retryMode ?? "spawn";
this.inflightRetryTtlMs = options.inflightRetryTtlMs ?? INFLIGHT_RETRY_TTL_MS;
if (options.logDirs !== undefined) {
Expand Down Expand Up @@ -286,6 +295,15 @@ export class DashboardData {
return this.read([], (stores) => stores.state.listCursors());
}

/** Working-window status from the worker process, or null when disabled. */
getScheduleSnapshot(): ScheduleSnapshot | null {
try {
return this.scheduleSnapshot();
} catch {
return null;
}
}

/**
* Tail the worker's capture files and link entries to their latest run.
* File reads are bounded (see `readWorkerLogs`); a missing DB only skips
Expand Down Expand Up @@ -626,6 +644,7 @@ export function handleWorkerStatus(data: DashboardData): ApiResponse {
lock === null ? null : { running: lock.running, pid: lock.pid, startedAt: lock.startedAt },
queue: data.getQueueStats(),
agentPrs: data.getAgentPrCounts(),
schedule: data.getScheduleSnapshot(),
cursors: data.getCursors().map((cursor) => ({
source: cursor.source,
cursorValue: cursor.cursorValue,
Expand Down
Loading
Loading