From 459c49d9044447a3587739e7ad3a1aae887dc3a3 Mon Sep 17 00:00:00 2001 From: Daniil Pokrovsky Date: Thu, 27 Aug 2026 16:54:24 +0700 Subject: [PATCH] feat: implement DEV-93 - Add quiet hours / scheduled drain to the worker so users can limit unattended work to chosen times (e.g. nights only) --- docs/code/automated-task-processing.md | 147 ++--- docs/code/worker.md | 20 +- docs/code/workspaces.md | 13 +- packages/code/USAGE.md | 18 +- packages/code/src/dashboard-server.ts | 8 +- packages/code/src/index.ts | 37 +- packages/code/src/lib/dashboard-api.ts | 18 + packages/code/src/lib/schedule.ts | 618 ++++++++++++++++++ .../code/src/lib/task-polling-acquirer.ts | 76 ++- packages/code/src/lib/worker-state.ts | 31 + packages/code/src/lib/workspace/config.ts | 17 + packages/code/src/lib/workspace/paths.ts | 9 + .../src/lib/workspace/workspace-worker.ts | 71 +- packages/code/tests/dashboard-api.test.ts | 27 + packages/code/tests/schedule.test.ts | 453 +++++++++++++ .../code/tests/task-polling-acquirer.test.ts | 125 +++- packages/code/tests/workspace-config.test.ts | 65 ++ packages/code/tests/workspace-state.test.ts | 16 + .../src/components/StatusStrip.tsx | 33 +- packages/dashboard-ui/src/lib/api.ts | 13 + 20 files changed, 1696 insertions(+), 119 deletions(-) create mode 100644 packages/code/src/lib/schedule.ts create mode 100644 packages/code/tests/schedule.test.ts diff --git a/docs/code/automated-task-processing.md b/docs/code/automated-task-processing.md index 6bfd77e..b75b3f3 100644 --- a/docs/code/automated-task-processing.md +++ b/docs/code/automated-task-processing.md @@ -3,12 +3,12 @@ title: "Automated Task Processing" description: "Drain your backlog continuously with the worker daemon" section: "Server Automation" order: 5 -dateModified: 2026-08-26 +dateModified: 2026-08-27 --- # Automated Task Processing -Run @devintern/code without sitting at the keyboard. The unattended path is the [worker daemon](./worker.md): one long-running process that polls your tracker, runs ready tasks, watches the agent's PRs, and can receive events in seconds through the [relay](./relay.md). +Run @devintern/code without sitting at the keyboard. The unattended path is the [worker daemon](./worker.md): one long-running process that polls your tracker, runs ready tasks, watches the agent's PRs, and can receive events in seconds through the [relay](./relay.md). [Working windows (quiet hours)](#working-windows-quiet-hours) keep that process resident while limiting when it spends tokens. ```bash devintern worker init @@ -31,96 +31,82 @@ Unattended execution (the worker, a systemd timer, cron, or any CI environment) Set `LICENSE_KEY` in the workspace `.env` (or as an `Environment=` entry in a unit file) to an automation license key from [devintern.com/account](https://devintern.com/account). Interactive runs (`devintern PROJ-123` from your terminal) are unaffected and do **not** require a license. @devintern/code is free to use interactively under the FSL license. -## Night-only CLI runs +## Working windows (quiet hours) -The worker polls whenever it is running. If you need a wall-clock window (for example only at night) until the worker has quiet hours, you can still fire a one-shot `devintern --query` from a systemd timer or crontab. This is a gap filler, not a second product. +The worker can limit new-task pickup from your tracker to wall-clock windows you choose — for example, only at night or only outside working hours. Configure them in `[worker.schedule]` in `workspace.toml` and restart the worker: -Story-point estimation is the other remaining one-shot: see [Story Points Estimation](./story-points-estimation.md). +```toml +[worker.schedule] +active = ["22:00-06:00"] # drain ready tasks only during these windows (local time) +blocked = ["12:00-13:00"] # optional: stay quiet during lunch even inside an active window +timezone = "" # optional IANA name ("America/New_York"); blank = machine local time +catch_up_missed = true # drain once at startup when a whole window elapsed unused +``` -Omit `--pr-target-branch` unless you intentionally want PRs against a non-default branch — the CLI detects `main`, `master`, or a custom default from the remote. +How it behaves: -### systemd timer (Linux) +- **Only new-task pickup is gated.** The detect → evaluate → execute drain of the fleet query pauses; review replies, @mentions, recurring automations, relay events, and everything else continue exactly as before. +- **In-flight tasks finish.** Execution is sequential: once a task has been picked up it runs to completion even if its window closes mid-run. Nothing is killed at the window edge. +- **Multiple windows union.** `active = ["06:00-09:00", "18:00-23:00"]` opens two drain periods per day. Windows may cross midnight (`22:00-06:00`). A start equal to the end is rejected. +- **Blocked wins over active.** Overlapping entries resolve toward staying quiet — the safe direction for spend. +- **No cursor movement while paused.** Ticks that land inside a quiet window neither query the tracker nor advance cursors, so anything created overnight is detected on the first tick after the window opens. -A systemd job is a pair of unit files: a one-shot `.service` that runs `devintern`, and a `.timer` that triggers it on a schedule. This example drains Intern-labeled work at 22:00 every night. +### Timezones and DST -`/etc/systemd/system/devintern-nightly.service`: +Windows are defined in **wall-clock time**. With no `timezone` set (the default), they follow the worker machine's local time, so "nights" mean *its* nights. Set any IANA name (for example `timezone = "Europe/Berlin"`) to pin the schedule independent of where the daemon runs; the resolved zone is printed in the startup banner and shown on the dashboard. -```ini -[Unit] -Description=Nightly Intern-labeled drain with @devintern/code -After=network-online.target -Wants=network-online.target +Daylight-saving transitions shift real window duration by up to an hour because windows track the clock, not absolute time: -[Service] -Type=oneshot -User=devintern -WorkingDirectory=/path/to/your/project -ExecStart=/usr/local/bin/devintern \ - --query 'statusCategory = "To Do" AND sprint in openSprints() AND labels IN (Intern) ORDER BY created DESC' \ - --max-turns 500 \ - --create-pr -StandardOutput=journal -StandardError=journal -``` +- **Spring forward**: local times that do not exist snap forward to the next valid moment — a `02:30` start begins within about half an hour of the lost hour instead of never firing. +- **Fall back**: a wall time occurring twice is evaluated on its second (standard-time) pass. -`/etc/systemd/system/devintern-nightly.timer`: +### Missed windows and catch-up -```ini -[Unit] -Description=Run @devintern/code at 22:00 +If the laptop slept through an entire active window (`catch_up_missed = true`, the default), the next worker start drains once immediately instead of waiting for the next window. The check compares the persisted timestamp of the last executed drain against the most recent fully elapsed window; set `catch_up_missed = false` if you would rather skip strictly to the next scheduled one. Worker uptime is unaffected either way: catch-up triggers only on startup. -[Timer] -OnCalendar=*-*-* 22:00:00 -Persistent=true +### Run now, without editing the schedule -[Install] -WantedBy=timers.target -``` - -Enable, start, and inspect: +`devintern worker run-now` asks the running worker for one immediate drain, ignoring the windows: ```bash -sudo systemctl daemon-reload -sudo systemctl enable --now devintern-nightly.timer +devintern worker run-now +devintern worker run-now --workspace /path/to/workspace.toml +``` -# Show next scheduled run -systemctl list-timers devintern-nightly.timer +The command writes a `.run-now` marker into the workspace home; the worker consumes it on its next poll tick (within `[defaults].poll_interval`, 60 seconds by default), drains, and removes the marker. Logs announce the manual run; the dashboard shows it as pending until served. -# Tail recent runs -journalctl -u devintern-nightly.service -f -``` +### Seeing the current state -### Cron +- The startup banner lists the windows, the timezone, and whether pickup is currently open. +- Every open/close flip is logged exactly once: `🌙 [schedule] outside the working window … ` / `☀️ [schedule] working window opened …`. +- The [dashboard](./dashboard.md) header shows the active state plus when the window next opens or closes; `/api/worker` exposes the same snapshot as JSON (`schedule`). -If you're on a system without systemd, the same night window works as a crontab entry: +The only CLI run still worth putting on a timer is story-point estimation, which the worker does not schedule yet — see [Story Points Estimation](./story-points-estimation.md). Omit `--pr-target-branch` there unless you intentionally want PRs against a non-default branch; the CLI detects `main`, `master`, or a custom default from the remote. -```bash -# Drain Intern-labeled tasks in open sprints at 22:00 -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 -``` +### Keeping unattended runs healthy -### Pin `PATH` so the `bun` shebang resolves +#### Pin `PATH` so the `bun` shebang resolves -The `devintern` binary is a `#!/usr/bin/env bun` script, so it needs **`bun` on `PATH`** to run. systemd services start with a minimal `PATH` that usually excludes wherever your version manager (mise, asdf, nvm) installed Bun. The unit then fails with `bun: command not found` (or silently can't find `devintern` itself). Pin `PATH` explicitly in the `[Service]` section, listing the directory that contains `bun` (and `devintern`): +The `devintern` binary is a `#!/usr/bin/env bun` script, so it needs **`bun` on `PATH`** to run. Services launched by init systems start with a minimal `PATH` that usually excludes wherever your version manager (mise, asdf, nvm) installed Bun, and then fail with `bun: command not found`. Pin `PATH` explicitly in the `[Service]` section, listing the directory that contains `bun` (and `devintern`): ```ini [Service] Environment="PATH=/home/youruser/.local/bin:/home/youruser/.local/share/mise/installs/bun/1.3.2/bin:/usr/local/bin:/usr/bin" ``` -Confirm the path with `dirname "$(which bun)"`. The same applies to cron, which also runs with a stripped `PATH`: either set `PATH=` at the top of the crontab or call `devintern` by absolute path. +Confirm the path with `dirname "$(which bun)"`. -### Running as a user service (no root) +#### Running as a user service (no root) -The examples above install to `/etc/systemd/system` (system-wide, needs `sudo`). You can instead run entirely as your own user with **`systemctl --user`**: no root, and the unit can read your `~/.ssh` and version-manager installs directly. Place the unit in `~/.config/systemd/user/`, drop the `User=` line, and manage it with `systemctl --user enable --now .timer`. To keep user services running after you log out, enable lingering once: +Instead of system units under `/etc/systemd/system` (which need `sudo`), you can run entirely as your own user with **`systemctl --user`**: no root, and the unit can read your `~/.ssh` and version-manager installs directly. Place the unit in `~/.config/systemd/user/`, drop the `User=` line, and manage it with `systemctl --user enable --now .service`. To keep user services running after you log out, enable lingering once: ```bash loginctl enable-linger "$USER" ``` -For a resident worker rather than a night timer, `devintern worker init` already writes a user-level systemd unit or macOS launchd agent. +`devintern worker init` already writes a user-level systemd unit (Linux) or launchd agent (macOS) for the resident worker. -### Git push under automation +#### Git push under automation If your repo's remote is SSH (`git@github.com:...`), the unattended run needs the SSH key reachable without an interactive agent. The cleanest approach is a `~/.ssh/config` host entry pointing the host at the right key: plain `git push` then resolves it (no `GIT_SSH_COMMAND` needed): @@ -131,54 +117,25 @@ Host github.com A `--user` service inherits your `$HOME` and reads this automatically; a system service with `User=` reads that user's `~/.ssh`. Alternatively, use an HTTPS remote with a `GITHUB_TOKEN`. -### Cleaning up processes the agent leaves running +#### Cleaning up processes the agent leaves running -While working a task, the AI agent often starts long-running processes to verify its changes: dev servers (`npm run dev`, `vite`), watchers, `docker compose up`, and so on. If the agent does not stop them, they would otherwise outlive the run and pile up across every scheduled execution. +While working a task, the AI agent often starts long-running processes to verify its changes: dev servers (`npm run dev`, `vite`), watchers, `docker compose up`, and so on. If the agent does not stop them, they would otherwise outlive the run and pile up across every execution. -@devintern/code prevents this. Each agent is launched in its own process group, and the entire group (the agent plus anything it spawned) is torn down when the run ends, times out, or is interrupted. This works the same under the worker, systemd, cron, and macOS, so you do not need to do anything to enable it. +@devintern/code prevents this. Each agent is launched in its own process group, and the entire group (the agent plus anything it spawned) is torn down when the run ends, times out, or is interrupted — the same under the worker, systemd timers, cron, and macOS, so you do not need to do anything to enable it: -Two layers back this up: +1. **In-process reaping (all platforms).** @devintern/code signals the whole process group on completion, on timeout, and on `SIGINT` / `SIGTERM` / `SIGHUP`. This protects schedulers without init-level cleanup of their own. +2. **systemd cgroup cleanup (Linux, bonus).** A systemd service confines all of its processes to a unit cgroup, and the default `KillMode=control-group` reaps that entire cgroup when the unit deactivates. This catches even processes that fully daemonize (call `setsid` themselves) and escape the process group. -1. **In-process reaping (all platforms).** @devintern/code signals the whole process group on completion, on timeout, and on `SIGINT` / `SIGTERM` / `SIGHUP`. This is what protects cron jobs and macOS, which have no init-level cleanup of their own. -2. **systemd cgroup cleanup (Linux, bonus).** A systemd service confines all of its processes to a unit cgroup, and the default `KillMode=control-group` reaps that entire cgroup when the unit deactivates. This catches even processes that fully daemonize (call `setsid` themselves) and so escape the process group. No extra configuration is required: any process the agent leaves behind is cleaned up when the oneshot service exits. - -If you run @devintern/code under cron on Linux and want the same daemon-proof guarantee systemd gives, wrap the command in a transient scope so its children share one cgroup: +If you wrap @devintern/code in a timer on Linux and want this daemon-proof guarantee from plain cron too, wrap the command in a transient scope so its children share one cgroup: ```bash -0 22 * * * cd /path/to/your/project && systemd-run --user --scope --collect devintern --query '...' --create-pr >> /tmp/devintern-cron.log 2>&1 +systemd-run --user --scope --collect devintern --estimate --query '...' >> /tmp/devintern-cron.log 2>&1 ``` -### Failure feedback on the task tracker +#### Failure feedback on the task tracker -A failed run never ends silently. When processing a task fails after it was moved to "In Progress" — an agent timeout, a usage limit, a crash, or the process being killed by `SIGTERM`/`SIGINT` (for example when a scheduler stops the job) — @devintern/code posts a comment on the ticket explaining that no pull request was created, the reason for the failure, and where partial work may live (the `feature/` branch or a git stash). The ticket is also moved back to its To Do status so the next scheduled run can retry it. +A failed run never ends silently. When processing a task fails after it was moved to "In Progress" — an agent timeout, a usage limit, a crash, or the process being killed by `SIGTERM`/`SIGINT` (for example when a machine powers off) — @devintern/code posts a comment on the ticket explaining that no pull request was created, the reason for the failure, and where partial work may live (the `feature/` branch or a git stash). The ticket is also moved back to its To Do status so the next pickup can retry it. The failure comment will not cause a retry loop: posting it does bump the tracker's update stamp, but the retry gate ignores the harness's own comments and records the attempt, so the ticket is only re-run after you edit the description, post your own comment, or delete the failure comment (see [worker polling](./worker.md#re-running-a-task)). Pass `--skip-comments` to disable all tracker comments, including failure feedback. - -### Linear schedules - -For Linear (`TASK_TRACKER=linear`), swap JQL for a JSON `IssueFilter`. Wrap the JSON in single quotes so the shell passes it through unchanged: - -```ini -# systemd service: nightly "intern"-labeled drain -ExecStart=/usr/local/bin/devintern \ - --query '{"labels":{"name":{"eq":"intern"}}}' \ - --max-turns 500 \ - --create-pr -``` - -```bash -# cron: nightly "intern"-labeled Linear drain -0 22 * * * cd /path/to/your/project && devintern --query '{"labels":{"name":{"eq":"intern"}}}' --max-turns 500 --create-pr >> /tmp/devintern-cron.log 2>&1 -``` - -### Notes for timer runs - -- Set `WorkingDirectory` (systemd) or `cd` (cron) to your project directory so the correct `.devintern-code/.env` is loaded -- Use absolute paths to the `devintern` and agent binaries, or pin `PATH` explicitly in the unit file (see above) -- Set `LICENSE_KEY` to an automation license key (Supporter, Team, or Business): unattended runs fail the license check without it -- `--query` is the preferred flag; `--jql` still works but emits a deprecation warning -- For systemd, `journalctl -u ` gives you logs; for cron, redirect stdout/stderr to a log file -- For Jira, use `ORDER BY created DESC` to process newest tasks first -- Test your query manually before scheduling (JQL in Jira's issue search; JSON `IssueFilter` in Linear's API explorer) diff --git a/docs/code/worker.md b/docs/code/worker.md index e175f22..1f31bde 100644 --- a/docs/code/worker.md +++ b/docs/code/worker.md @@ -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 @@ -146,6 +146,22 @@ The worker log is the diagnostic. Look for `[poll:]` (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`: @@ -212,7 +228,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 diff --git a/docs/code/workspaces.md b/docs/code/workspaces.md index 29c8a6e..ab83e8b 100644 --- a/docs/code/workspaces.md +++ b/docs/code/workspaces.md @@ -3,7 +3,7 @@ title: "Workspaces (Multi-Repo Fleet)" description: "Drive many repositories with one devintern worker: a single workspace.toml, routing rules, and per-task worktrees" section: "Server Automation" order: 1 -dateModified: 2026-08-26 +dateModified: 2026-08-27 --- # Workspaces (Multi-Repo Fleet) @@ -58,6 +58,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 @@ -76,6 +82,7 @@ prompt = "Review the frontend and clean up one source of recurring noise." - `[defaults].tracker` picks the tracker for the fleet query; any tracker with polling support works (Jira, Linear, GitHub Issues, Azure DevOps, Asana, Trello, Markdown). - 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. ### How workspace automations differ from single-repo ones @@ -121,7 +128,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: diff --git a/packages/code/USAGE.md b/packages/code/USAGE.md index 6da14f4..d85269b 100644 --- a/packages/code/USAGE.md +++ b/packages/code/USAGE.md @@ -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 diff --git a/packages/code/src/dashboard-server.ts b/packages/code/src/dashboard-server.ts index e946e81..fe202b7 100644 --- a/packages/code/src/dashboard-server.ts +++ b/packages/code/src/dashboard-server.ts @@ -30,6 +30,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; } /** Resolve the built dashboard UI directory, or null when not shipped/built. */ @@ -87,7 +89,11 @@ export function startDashboardServer( const port = options.port ?? parseInt(process.env.DASHBOARD_PORT || String(DEFAULT_DASHBOARD_PORT), 10); const host = options.host ?? "127.0.0.1"; - const data = new DashboardData({ dbPath: options.dbPath, workingDir: options.workingDir }); + const data = new DashboardData({ + dbPath: options.dbPath, + workingDir: options.workingDir, + scheduleSnapshot: options.scheduleSnapshot, + }); const uiDir = resolveUiDir(); if (host !== "127.0.0.1" && host !== "localhost") { diff --git a/packages/code/src/index.ts b/packages/code/src/index.ts index aee20ab..cc0a59b 100755 --- a/packages/code/src/index.ts +++ b/packages/code/src/index.ts @@ -617,6 +617,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 ]"); + 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") { @@ -683,7 +717,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"); @@ -699,6 +733,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 Use this workspace.toml (default: ~/.devintern/"); diff --git a/packages/code/src/lib/dashboard-api.ts b/packages/code/src/lib/dashboard-api.ts index ef0b45c..6e9c440 100644 --- a/packages/code/src/lib/dashboard-api.ts +++ b/packages/code/src/lib/dashboard-api.ts @@ -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 { resolveQueueDbPath, WebhookQueue } from "./webhook-queue"; import { WorkerState } from "./worker-state"; import type { Cursor } from "./worker-state"; @@ -50,6 +51,11 @@ 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; } interface Stores { @@ -69,10 +75,12 @@ export class DashboardData { readonly dbPath: string; readonly workingDir: string; private stores: Stores | null = null; + private readonly scheduleSnapshot: () => ScheduleSnapshot | null; constructor(options: DashboardDataOptions = {}) { this.dbPath = options.dbPath ?? resolveQueueDbPath(); this.workingDir = options.workingDir ?? process.cwd(); + this.scheduleSnapshot = options.scheduleSnapshot ?? (() => null); } /** Open (or reuse) the read-only stores; null while the DB file is missing. */ @@ -150,6 +158,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; + } + } + /** Close the underlying SQLite connections (tests, shutdown). */ close(): void { if (this.stores) { @@ -249,6 +266,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, diff --git a/packages/code/src/lib/schedule.ts b/packages/code/src/lib/schedule.ts new file mode 100644 index 0000000..bcc4ba1 --- /dev/null +++ b/packages/code/src/lib/schedule.ts @@ -0,0 +1,618 @@ +/** + * Working windows (quiet hours) for new-task pickup. + * + * A schedule is a list of daily wall-clock windows in which the worker may + * pick up ready tasks from the tracker (`active`), minus `blocked` windows + * in which it must stay idle. Everything else — review replies, mentions, + * automations, relay events — is unaffected: this gates only the + * detect → evaluate → execute drain of the task-polling acquirer. + * + * Time is wall-clock in one zone: an optional IANA timezone, or the machine's + * local time when none is set. Because windows are defined against the local + * clock rather than absolute instants, DST transitions shift real window + * duration by up to an hour (spring-forward shortens or skips times inside + * the lost hour; fall-back repeats the same wall clock once). Transitions are + * resolved on a best-effort minute grid and never crash polling. + */ + +import { existsSync, unlinkSync } from "fs"; + +const WINDOW_SPEC_PATTERN = /^(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})$/; +const MINUTES_PER_DAY = 24 * 60; + +/** One parsed daily window from a `"HH:MM-HH:MM"` spec. */ +export interface ParsedTimeWindow { + /** Original config string, used verbatim in logs. */ + spec: string; + /** Minutes after midnight the window opens (inclusive). */ + startMinutes: number; + /** Minutes after midnight the window closes (exclusive). */ + endMinutes: number; +} + +/** Validated `[worker.schedule]` table. */ +export interface WorkerScheduleConfig { + /** Pickup allowed only during these windows; empty means any time. */ + active: ParsedTimeWindow[]; + /** Pickup suppressed during these windows even inside active ones. */ + blocked: ParsedTimeWindow[]; + /** IANA timezone name; undefined uses the machine's local time. */ + timezone?: string; + /** Drain once at startup when the most recent window fully elapsed unused. */ + catchUpMissed: boolean; +} + +export const DEFAULT_CATCH_UP_MISSED = true; + +/** + * Parse one `"HH:MM-HH:MM"` window spec. + * + * Overnight windows simply have start > end (`22:00-06:00`). Start equal to + * end is rejected because it can only ever mean "no minutes" or "all day" + * ambiguously. + * + * @throws With a user-facing message when the spec is malformed. + */ +export function parseTimeWindowSpec(rawSpec: string): ParsedTimeWindow { + const spec = rawSpec.trim(); + const match = WINDOW_SPEC_PATTERN.exec(spec); + if (!match) { + throw new Error( + `"${rawSpec}" is not a time window. Use "HH:MM-HH:MM", e.g. "22:00-06:00" (overnight is fine).`, + ); + } + const parts = [ + [match[1], match[2], "start"], + [match[3], match[4], "end"], + ] as const; + const totals: number[] = []; + for (const [hourText, minuteText, edge] of parts) { + const hour = Number(hourText); + const minute = Number(minuteText); + if (hour > 23 || minute > 59) { + throw new Error(`"${spec}" has an invalid ${edge} time ${hourText}:${minuteText} (HH:MM).`); + } + totals.push(hour * 60 + minute); + } + if (totals[0] === totals[1]) { + throw new Error(`"${spec}" starts and ends at the same time; use two windows instead.`); + } + return { spec, startMinutes: totals[0] as number, endMinutes: totals[1] as number }; +} + +function pad2(value: number): string { + return String(value).padStart(2, "0"); +} + +/** Canonical display form of a parsed window. */ +export function formatTimeWindow(window: ParsedTimeWindow): string { + const hhmm = (total: number) => `${pad2(Math.floor(total / 60))}:${pad2(total % 60)}`; + return `${hhmm(window.startMinutes)}-${hhmm(window.endMinutes)}`; +} + +/** + * Parse the `[worker.schedule]` TOML table (already reduced to raw values). + * + * Collects every problem into `errors` so a broken config is fixable in one + * pass; returns `config: null` when the section is absent or empty (schedule + * disabled). + */ +export function parseWorkerScheduleSection( + value: unknown, + label = "[worker.schedule]", +): { config: WorkerScheduleConfig | null; errors: string[] } { + const errors: string[] = []; + if (value === undefined || value === null) { + return { config: null, errors }; + } + if (typeof value !== "object" || Array.isArray(value)) { + return { + config: null, + errors: [`${label} must be a table with \`active\`, \`blocked\`, and \`timezone\` keys.`], + }; + } + const table = value as Record; + + const readWindows = (key: "active" | "blocked"): ParsedTimeWindow[] => { + const raw = table[key]; + if (raw === undefined || raw === null) { + return []; + } + if (!Array.isArray(raw)) { + errors.push(`${label}.${key} must be an array of time window strings ("22:00-06:00").`); + return []; + } + const windows: ParsedTimeWindow[] = []; + for (const item of raw) { + if (typeof item !== "string") { + errors.push(`${label}.${key} entries must be strings like "22:00-06:00".`); + continue; + } + try { + windows.push(parseTimeWindowSpec(item)); + } catch (error) { + errors.push(`${label}.${key}: ${(error as Error).message}`); + } + } + return windows; + }; + + const active = readWindows("active"); + const blocked = readWindows("blocked"); + + let timezone: string | undefined; + const rawTimezone = table.timezone; + if (rawTimezone !== undefined && rawTimezone !== null) { + if (typeof rawTimezone !== "string") { + errors.push(`${label}.timezone must be a string (an IANA name like "America/New_York").`); + } else if (!rawTimezone.trim()) { + // Blank string stays machine-local by convention. + } else if (!isValidTimeZone(rawTimezone.trim())) { + errors.push(`${label}.timezone "${rawTimezone.trim()}" is not a valid IANA timezone name.`); + } else { + timezone = rawTimezone.trim(); + } + } + + let catchUpMissed = DEFAULT_CATCH_UP_MISSED; + const rawCatchUp = table.catch_up_missed; + if (rawCatchUp !== undefined && rawCatchUp !== null) { + if (typeof rawCatchUp !== "boolean") { + errors.push(`${label}.catch_up_missed must be a boolean.`); + } else { + catchUpMissed = rawCatchUp; + } + } + + if (errors.length > 0) { + return { config: null, errors }; + } + + // An empty section says nothing about scheduling; treat it as disabled so + // existing configs that merely pre-create the table keep working. + if (active.length === 0 && blocked.length === 0) { + return { config: null, errors }; + } + + return { config: { active, blocked, timezone, catchUpMissed }, errors }; +} + +export function isValidTimeZone(zone: string): boolean { + try { + new Intl.DateTimeFormat("en-US", { timeZone: zone }); + return true; + } catch { + return false; + } +} + +/** Whether `minutes` past midnight falls inside a daily window (end-exclusive, wrap-aware). */ +function containsMinute(window: ParsedTimeWindow, minutes: number): boolean { + if (window.startMinutes < window.endMinutes) { + return minutes >= window.startMinutes && minutes < window.endMinutes; + } + // Overnight window wraps around midnight. + return minutes >= window.startMinutes || minutes < window.endMinutes; +} + +/** + * Whether new-task pickup is allowed at a given minute of day. + * + * Blocked windows always win over active ones: overlapping/conflicting + * entries resolve to "stay quiet", which is the safe direction. + */ +export function pickupAllowedAt(schedule: WorkerScheduleConfig, minutesOfDay: number): boolean { + if (schedule.blocked.some((w) => containsMinute(w, minutesOfDay))) { + return false; + } + if (schedule.active.length === 0) { + return true; + } + return schedule.active.some((w) => containsMinute(w, minutesOfDay)); +} + +interface WallYMDhM { + year: number; + month: number; + day: number; + hour: number; + minute: number; +} + +/** + * Wall-clock reader/converter for one timezone (undefined = system local). + * + * All reading goes through Intl formatting; all construction from wall time + * iterates offset guesses to convergence, which handles fixed offsets and + * both DST directions deterministically: + * + * - Fall-back repeated hour resolves to the later (standard-time) occurrence. + * - Spring-forward gap snaps forward to the first instant whose clock has + * passed the requested time (a 02:30 window starts when the clock next + * shows ≥02:30 that day). + */ +export class WallClock { + private readonly formatter: Intl.DateTimeFormat; + + constructor(readonly timezone?: string) { + this.formatter = new Intl.DateTimeFormat("en-US", { + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + ...(timezone ? { timeZone: timezone } : {}), + }); + } + + wallAt(atMs: number): WallYMDhM { + const parts = this.formatter.formatToParts(new Date(atMs)); + const get = (type: Intl.DateTimeFormatPartTypes): number => { + const part = parts.find((p) => p.type === type); + return part ? Number(part.value) : NaN; + }; + let hour = get("hour"); + if (!(hour >= 0)) hour = 0; + if (hour === 24) hour = 0; + return { + year: get("year"), + month: get("month"), + day: get("day"), + hour, + minute: get("minute"), + }; + } + + /** Minutes after local midnight right now. */ + minutesOfDay(atMs: number = Date.now()): number { + const wall = this.wallAt(atMs); + return wall.hour * 60 + wall.minute; + } + + /** + * The instant at which this zone's wall clock reads `minutes` past + * midnight on the local calendar day containing `sameLocalDayMs`. + */ + instantAt(sameLocalDayMs: number, minutes: number): number { + const wall = this.wallAt(sameLocalDayMs); + const target = Date.UTC( + wall.year, + wall.month - 1, + wall.day, + Math.floor(minutes / 60), + minutes % 60, + ); + let candidate = target; + for (let i = 0; i < 3; i++) { + const skew = this.wallAsUtc(candidate) - candidate; + candidate = target - skew; + } + // Spring-forward gap: no exact solution exists; snap forward onto the + // first moment the wall clock reads at or past the requested time. + for (let i = 0; i < 32 && this.wallAsUtc(candidate) - target < 0; i++) { + candidate += 10 * 60_000; + } + return this.resolveAmbiguousToLaterOccurrence(candidate, target); + } + + /** + * Fall-back repeats a wall time twice; the documented policy resolves it + * to the later (standard-time) occurrence by probing forward briefly for + * a second exact rendering of the same wall clock on the same date. + */ + private resolveAmbiguousToLaterOccurrence(candidate: number, target: number): number { + // Cheap guard: a wall-time duplicate can only exist when a DST + // transition sits within a few hours of this instant. + const offsetAt = (atMs: number): number => this.wallAsUtc(atMs) - atMs; + let minOffset = Number.POSITIVE_INFINITY; + let maxOffset = Number.NEGATIVE_INFINITY; + for (let delta = -3 * 3_600_000; delta <= 3 * 3_600_000; delta += 1_800_000) { + const offset = offsetAt(candidate + delta); + if (offset < minOffset) minOffset = offset; + if (offset > maxOffset) maxOffset = offset; + } + if (maxOffset === minOffset) { + return candidate; + } + let latest = candidate; + const rendersWall = (atMs: number): boolean => this.wallAsUtc(atMs) === target; + for (let probe = candidate + 300_000; probe <= candidate + 120 * 60_000; probe += 300_000) { + if (rendersWall(probe)) { + latest = probe; + } + } + return rendersWall(latest) ? latest : candidate; + } + + private wallAsUtc(atMs: number): number { + const wall = this.wallAt(atMs); + return Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute); + } + + /** Local calendar dates around `fromMs`, each as its UTC-midnight anchor ms. */ + dayAnchors(fromMs: number, back: number, forward: number): number[] { + const wall = this.wallAt(fromMs); + const todayUtcMidnight = Date.UTC(wall.year, wall.month - 1, wall.day); + const anchors: number[] = []; + for (let offset = -back; offset <= forward; offset++) { + anchors.push(todayUtcMidnight + offset * 86_400_000); + } + return anchors; + } +} + +export interface ScheduleTransition { + at: number; + kind: "open" | "close"; +} + +/** Human description of one scheduled window (raw specs are shown elsewhere). */ +export function describeNextChangeLabel(kind: ScheduleTransition["kind"], at: number): string { + const time = new Date(at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + return kind === "open" ? `pickup opens at ${time}` : `pickup closes at ${time}`; +} + +/** + * Next instant at which pickup availability flips. + * + * Candidate flip points are every start/end of every configured window; the + * first candidate that actually changes availability wins, so overlapping + * windows behave like their union (minus blocked) instead of producing + * confusing no-op transitions. + */ +export function nextTransition( + schedule: WorkerScheduleConfig, + clock: WallClock, + fromMs: number = Date.now(), +): ScheduleTransition | null { + const candidates: number[] = []; + for (const anchor of clock.dayAnchors(fromMs, 1, 9)) { + for (const window of [...schedule.active, ...schedule.blocked]) { + candidates.push(clock.instantAt(anchor, window.startMinutes)); + const endAnchor = window.endMinutes <= window.startMinutes ? anchor + 86_400_000 : anchor; + candidates.push(clock.instantAt(endAnchor, window.endMinutes)); + } + } + candidates.sort((a, b) => a - b); + + let previousAllowed = pickupAllowedAt(schedule, clock.minutesOfDay(fromMs)); + for (const at of candidates) { + if (at <= fromMs) continue; + const allowed = pickupAllowedAt(schedule, clock.minutesOfDay(at)); + if (allowed !== previousAllowed) { + return { at, kind: allowed ? "open" : "close" }; + } + previousAllowed = allowed; + } + return null; +} + +/** The latest daily-window occurrence that fully elapsed before `nowMs`. */ +export function lastElapsedActiveWindow( + schedule: WorkerScheduleConfig, + clock: WallClock, + nowMs: number, + lookbackDays = 8, +): { startedAt: number; endedAt: number } | null { + let latest: { startedAt: number; endedAt: number } | null = null; + for (const anchor of clock.dayAnchors(nowMs, lookbackDays, 0)) { + for (const window of schedule.active) { + const startedAt = clock.instantAt(anchor, window.startMinutes); + const endAnchor = window.endMinutes <= window.startMinutes ? anchor + 86_400_000 : anchor; + const endedAt = clock.instantAt(endAnchor, window.endMinutes); + if (endedAt > nowMs || endedAt <= startedAt) continue; + if (!latest || endedAt > latest.endedAt) { + latest = { startedAt, endedAt }; + } + } + } + return latest; +} + +/** + * Catch-up eligibility: the last elapsed active window began after the final + * drain, meaning the worker was down/asleep/idle through that entire window. + * Calling this repeatedly outside a window stays eligible until the next + * window actually drains; callers invoke it once per process start. + */ +export function missedMostRecentWindow( + schedule: WorkerScheduleConfig, + clock: WallClock, + lastDrainAt: number | null, + nowMs: number = Date.now(), +): boolean { + if (pickupAllowedAt(schedule, clock.minutesOfDay(nowMs))) { + return false; // Window is open right now; normal draining applies. + } + if (schedule.active.length === 0) { + return false; // Nothing bounded was missed. + } + const lastWindow = lastElapsedActiveWindow(schedule, clock, nowMs); + if (!lastWindow) { + return false; + } + return lastDrainAt === null || lastDrainAt < lastWindow.startedAt; +} + +/** Serializable state shown in logs, the CLI banner, and `/api/worker`. */ +export interface ScheduleSnapshot { + enabled: boolean; + pickupAllowed: boolean; + /** Raw window specs (`"22:00-06:00"`), active then blocked. */ + active: string[]; + blocked: string[]; + /** Resolved timezone name, e.g. `"Europe/Berlin"` (system default included). */ + timezone: string; + catchUpMissed: boolean; + manualRequested: boolean; + nextChange?: ScheduleTransition; +} + +export interface PickupGateDeps { + /** + * Sentinel path signaling one immediate drain (manual "run now"); checked + * and consumed on every tick evaluation. + */ + runNowPath?: string; + /** Injectable clock (tests); defaults to the system clock via the zone. */ + now?: () => number; +} + +/** + * Decides whether the task-polling acquirer may pick up new tracker work. + * + * Also owns side concerns keyed off scheduling: consuming the run-now + * sentinel, detecting open/close flips for listeners, and answering the + * startup catch-up question. Tracker/network access never happens here. + */ +export interface PickupGate { + readonly enabled: boolean; + /** True when a new detect→evaluate→execute tick may start right now. */ + pickupAllowed(): boolean; + /** One-shot manual override; consumes a pending run-now request. */ + consumeManualPickup(): boolean; + /** Startup catch-up: drain once although the gate is closed. */ + shouldCatchUpOnStart(lastDrainAt: number | null): boolean; + /** Current status snapshot (cheap enough to call per dashboard poll). */ + snapshot(): ScheduleSnapshot; + /** Registers a listener fired whenever availability flips. */ + onChange(listener: (snapshot: ScheduleSnapshot) => void): void; +} + +class ScheduledPickupGate implements PickupGate { + readonly enabled = true; + private readonly clock: WallClock; + private readonly config: WorkerScheduleConfig; + private readonly deps: PickupGateDeps; + private readonly listeners: Array<(snapshot: ScheduleSnapshot) => void> = []; + private lastState: boolean | null = null; + + constructor(config: WorkerScheduleConfig, deps: PickupGateDeps = {}) { + this.config = config; + this.deps = deps; + this.clock = new WallClock(config.timezone); + } + + private peekRunNowFile(): boolean { + return Boolean(this.deps.runNowPath && existsSync(this.deps.runNowPath)); + } + + pickupAllowed(): boolean { + const nowMs = this.nowMs(); + const manualPending = this.peekRunNowFile(); + const allowed = manualPending || pickupAllowedAt(this.config, this.clock.minutesOfDay(nowMs)); + this.publishIfFlipped(allowed, nowMs); + return allowed; + } + + consumeManualPickup(): boolean { + if (!this.deps.runNowPath) return false; + if (!existsSync(this.deps.runNowPath)) return false; + try { + unlinkSync(this.deps.runNowPath); + } catch { + // Another tick already consumed it; harmless. + } + return true; + } + + shouldCatchUpOnStart(lastDrainAt: number | null): boolean { + if (!this.config.catchUpMissed) return false; + return missedMostRecentWindow(this.config, this.clock, lastDrainAt, this.nowMs()); + } + + snapshot(): ScheduleSnapshot { + const nowMs = this.nowMs(); + const manualRequested = this.peekRunNowFile(); + return { + enabled: true, + pickupAllowed: + manualRequested || pickupAllowedAt(this.config, this.clock.minutesOfDay(nowMs)), + active: this.config.active.map((w) => w.spec), + blocked: this.config.blocked.map((w) => w.spec), + timezone: this.clock.timezone ?? currentSystemTimeZone(), + catchUpMissed: this.config.catchUpMissed, + manualRequested, + nextChange: nextTransition(this.config, this.clock, nowMs) ?? undefined, + }; + } + + onChange(listener: (snapshot: ScheduleSnapshot) => void): void { + this.listeners.push(listener); + } + + private nowMs(): number { + return this.deps.now ? this.deps.now() : Date.now(); + } + + private publishIfFlipped(allowed: boolean, nowMs: number): void { + if (this.lastState === allowed) return; + const hadPriorState = this.lastState !== null; + this.lastState = allowed; + if (!hadPriorState) return; // Startup state reaches listeners via the banner. + const snapshot = this.buildSnapshot(allowed, nowMs); + for (const listener of this.listeners) { + try { + listener(snapshot); + } catch { + // Listeners are observability sugar; never break polling. + } + } + } + + private buildSnapshot(allowed: boolean, nowMs: number): ScheduleSnapshot { + return { + enabled: true, + pickupAllowed: allowed, + active: this.config.active.map((w) => w.spec), + blocked: this.config.blocked.map((w) => w.spec), + timezone: this.clock.timezone ?? currentSystemTimeZone(), + catchUpMissed: this.config.catchUpMissed, + manualRequested: this.peekRunNowFile(), + nextChange: nextTransition(this.config, this.clock, nowMs) ?? undefined, + }; + } +} + +/** Gate for schedules that do not restrict anything (no `[worker.schedule]`). */ +class OpenPickupGate implements PickupGate { + readonly enabled = false; + pickupAllowed(): boolean { + return true; + } + consumeManualPickup(): boolean { + return false; + } + shouldCatchUpOnStart(_lastDrainAt: number | null): boolean { + return false; + } + snapshot(): ScheduleSnapshot { + return { + enabled: false, + pickupAllowed: true, + active: [], + blocked: [], + timezone: currentSystemTimeZone(), + catchUpMissed: DEFAULT_CATCH_UP_MISSED, + manualRequested: false, + }; + } + onChange(): void {} +} + +/** Build the gate for a parsed schedule; `null` config yields an always-open gate. */ +export function createPickupGate( + config: WorkerScheduleConfig | null, + deps: PickupGateDeps = {}, +): PickupGate { + return config ? new ScheduledPickupGate(config, deps) : new OpenPickupGate(); +} + +export function currentSystemTimeZone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone ?? "system"; + } catch { + return "system"; + } +} diff --git a/packages/code/src/lib/task-polling-acquirer.ts b/packages/code/src/lib/task-polling-acquirer.ts index b11db32..45780cb 100644 --- a/packages/code/src/lib/task-polling-acquirer.ts +++ b/packages/code/src/lib/task-polling-acquirer.ts @@ -19,6 +19,8 @@ import { spawn } from "child_process"; import type { ChangeDetector } from "./change-detector"; +import type { PickupGate } from "./schedule"; +import { TASK_POLL_LAST_DRAIN_KEY } from "./worker-state"; import type { WebhookQueue } from "./webhook-queue"; import type { WorkerState } from "./worker-state"; import type { Acquirer } from "../worker"; @@ -52,6 +54,13 @@ export interface TaskPollingAcquirerOptions { searchTasks: (query: string) => Promise<{ tasks: ReadyTask[] }>; /** Execute step: process, fail, or defer one ready task (injected for tests). */ executeTask: (taskKey: string) => Promise; + /** + * Working-window gate (quiet hours). When closed, ticks start no new + * detection/evaluation/execution; an in-flight tick finishes naturally + * because execution is sequential. Manual overrides and startup catch-up + * are the gate's decisions surfaced as one-shot bypasses. + */ + gate?: PickupGate; verbose?: boolean; } @@ -99,6 +108,7 @@ export class TaskPollingAcquirer implements Acquirer { private options: TaskPollingAcquirerOptions; private timer: ReturnType | null = null; private busy = false; + private readonly gateErrors = new Set(); constructor(options: TaskPollingAcquirerOptions) { this.options = options; @@ -111,10 +121,29 @@ export class TaskPollingAcquirer implements Acquirer { `🔎 Polling ${this.options.trackerType} every ${this.options.intervalSeconds}s ` + `(query: ${this.options.query})`, ); - await this.tick(); + const lastDrainAt = this.readLastDrainAt(); + if (this.options.gate?.shouldCatchUpOnStart(lastDrainAt)) { + // The laptop slept through the entire previous window; drain once now + // instead of waiting for the next one. + console.log(`🌙 [${this.name}] working window(s) elapsed while idle; running catch-up drain`); + await this.tick({ ignoreGate: true }); + } else { + await this.tick(); + } this.timer = setInterval(() => void this.tick(), this.options.intervalSeconds * 1000); } + private readLastDrainAt(): number | null { + try { + const raw = this.options.workerState.getMeta(TASK_POLL_LAST_DRAIN_KEY); + if (!raw) return null; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : null; + } catch { + return null; + } + } + /** Stop polling (an in-flight tick finishes its current task). */ stop(): void { if (this.timer) { @@ -123,11 +152,29 @@ export class TaskPollingAcquirer implements Acquirer { } } - /** One detect → evaluate → dedupe → execute cycle. Skipped while busy. */ - async tick(): Promise { + /** + * One detect → evaluate → dedupe → execute cycle. Skipped while busy, and + * skipped while the working-window gate is closed (unless overridden for a + * manual run or startup catch-up). + */ + async tick(bypass: { ignoreGate?: boolean } = {}): Promise { if (this.busy) { return; } + + const gate = this.options.gate; + if (!bypass.ignoreGate && gate) { + const manual = this.scheduleGuard(() => gate.consumeManualPickup(), false); + if (manual) { + console.log(`▶️ [${this.name}] manual run requested; draining now`); + } else if (!this.scheduleGuard(() => gate.pickupAllowed(), true)) { + // Outside the working window: no detection, no evaluation, no new + // tasks. Whatever is already running finishes before this check even + // happens, and no cursor moves while gated out. + return; + } + } + this.busy = true; const { detector, workerState, queue, query, searchTasks, executeTask, verbose } = this.options; @@ -178,6 +225,12 @@ export class TaskPollingAcquirer implements Acquirer { } this.logEvaluate(tasks.length, skipped, missingStamp, pickedUp, verbose); + // Remember that a drain ran so working-window catch-up can tell an + // elapsed-but-idle window apart from one that was already served. + this.scheduleGuard( + () => workerState.setMeta(TASK_POLL_LAST_DRAIN_KEY, String(Date.now())), + undefined, + ); } if (!tickDeferred && detection.nextCursor !== null && detection.nextCursor !== cursor) { @@ -190,6 +243,23 @@ export class TaskPollingAcquirer implements Acquirer { } } + /** + * Scheduling must never break polling: gate or bookkeeping failures are + * downgraded to a single warning per error identity. + */ + private scheduleGuard(operation: () => T, fallback: T): T { + try { + return operation(); + } catch (error) { + const message = (error as Error).message; + if (!this.gateErrors.has(message)) { + this.gateErrors.add(message); + console.warn(`⚠️ [${this.name}] schedule check failed: ${message}`); + } + return fallback; + } + } + /** * Always-on skip/stamp diagnosis. Silent skips made "ticket matches query * but was never picked up" undebuggable from the worker log. diff --git a/packages/code/src/lib/worker-state.ts b/packages/code/src/lib/worker-state.ts index f9a3150..8a9b4bf 100644 --- a/packages/code/src/lib/worker-state.ts +++ b/packages/code/src/lib/worker-state.ts @@ -23,6 +23,9 @@ export interface Cursor { export type AgentPrState = "open" | "closed"; +/** `worker_meta` key holding the epoch ms of the last executed task drain. */ +export const TASK_POLL_LAST_DRAIN_KEY = "task-poll:last-drain-at"; + export interface AgentPr { repo: string; // owner/repo prNumber: number; @@ -104,6 +107,14 @@ export class WorkerState { CREATE INDEX IF NOT EXISTS idx_agent_prs_state ON agent_prs(state) `); + + this.db.run(` + CREATE TABLE IF NOT EXISTS worker_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL + ) + `); } /** @@ -272,6 +283,26 @@ export class WorkerState { ); } + /** + * Read one metadata value (e.g. the last task-drain timestamp used by + * working-window catch-up). Returns null when never written. + */ + getMeta(key: string): string | null { + const row = this.db.query(`SELECT value FROM worker_meta WHERE key = ?`).get(key) as { + value: string; + } | null; + return row?.value ?? null; + } + + /** Upsert one metadata value. */ + setMeta(key: string, value: string): void { + this.db.run( + `INSERT INTO worker_meta (key, value, updated_at) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + [key, value, Date.now()], + ); + } + /** Close the underlying SQLite connection. */ close(): void { this.db.close(); diff --git a/packages/code/src/lib/workspace/config.ts b/packages/code/src/lib/workspace/config.ts index 2f9118d..5398b11 100644 --- a/packages/code/src/lib/workspace/config.ts +++ b/packages/code/src/lib/workspace/config.ts @@ -3,6 +3,8 @@ import { readFileSync } from "fs"; import { supportsPolling, trackersSupportingPolling } from "../tracker-capabilities"; import { parseAutomationEntries } from "../automation-config"; import type { AutomationConfig } from "../automation-config"; +import { parseWorkerScheduleSection } from "../schedule"; +import type { WorkerScheduleConfig } from "../schedule"; import { parseToml } from "./toml"; /** Workspace-wide settings from the `[workspace]` table. */ @@ -55,9 +57,19 @@ export interface RoutingRule { labels: string[]; } +/** Worker-scoped settings from the `[worker]` table. */ +export interface WorkerSettings { + /** + * Working windows (quiet hours) gating new-task pickup; null when the + * `[worker.schedule]` table is absent or empty (pickup unrestricted). + */ + schedule: WorkerScheduleConfig | null; +} + /** Parsed and validated `workspace.toml`. */ export interface WorkspaceConfig { workspace: WorkspaceSettings; + worker: WorkerSettings; defaults: WorkspaceDefaults; repos: RepoConfig[]; routing: RoutingRule[]; @@ -325,12 +337,17 @@ export function parseWorkspaceConfig( }); errors.push(...automationResult.errors); + const workerTable = asTable(document.worker, "[worker]", errors); + const schedule = parseWorkerScheduleSection(workerTable.schedule, "[worker.schedule]"); + errors.push(...schedule.errors); + if (errors.length > 0) { throw new Error(`Invalid ${sourceLabel}:\n- ${errors.join("\n- ")}`); } return { workspace: { worktreesTtlDays, dashboard, dashboardPort }, + worker: { schedule: schedule.config }, defaults, repos, routing, diff --git a/packages/code/src/lib/workspace/paths.ts b/packages/code/src/lib/workspace/paths.ts index a189d37..d4b3282 100644 --- a/packages/code/src/lib/workspace/paths.ts +++ b/packages/code/src/lib/workspace/paths.ts @@ -33,6 +33,15 @@ export function workspaceDbPath(workspaceDir: string = resolveWorkspaceDir()): s return join(workspaceDir, "state", "queue.db"); } +/** + * Path of the "run now" sentinel. `devintern worker run-now` creates this + * file; a running worker consumes it on its next poll tick and drains once, + * ignoring working windows. + */ +export function workspaceRunNowPath(workspaceDir: string = resolveWorkspaceDir()): string { + return join(workspaceDir, ".run-now"); +} + /** Directory holding the worker-managed bare clones (`repos/.git`). */ export function reposDir(workspaceDir: string = resolveWorkspaceDir()): string { return join(workspaceDir, "repos"); diff --git a/packages/code/src/lib/workspace/workspace-worker.ts b/packages/code/src/lib/workspace/workspace-worker.ts index 7899b96..0c3ba40 100644 --- a/packages/code/src/lib/workspace/workspace-worker.ts +++ b/packages/code/src/lib/workspace/workspace-worker.ts @@ -15,6 +15,8 @@ import { parseEnvInteger } from "../env-integer"; import { TaskPollingAcquirer, runTaskViaCli, workerTaskArgs } from "../task-polling-acquirer"; import type { TaskExecutionResult } from "../task-polling-acquirer"; import type { ChangeDetector } from "../change-detector"; +import { createPickupGate } from "../schedule"; +import type { PickupGate, ScheduleSnapshot } from "../schedule"; import type { WebhookQueue } from "../webhook-queue"; import type { WorkerState } from "../worker-state"; import { findRepo, loadWorkspaceConfig } from "./config"; @@ -25,6 +27,7 @@ import { workspaceConfigPath, workspaceDbPath, workspaceEnvPath, + workspaceRunNowPath, } from "./paths"; import { routeTask, toRoutableTask } from "./router"; import type { RoutableTask } from "./router"; @@ -65,6 +68,8 @@ export interface WorkspaceTaskAcquirerDeps { searchTasks: (query: string) => Promise<{ tasks: FleetTask[] }>; query: string; intervalSeconds: number; + /** Working-window gate (quiet hours); optional so tests can skip it. */ + gate?: PickupGate; verbose?: boolean; /** Task runner (injected for tests; defaults to the CLI subprocess). */ runTask?: ( @@ -204,6 +209,7 @@ export function createWorkspaceTaskAcquirer(deps: WorkspaceTaskAcquirerDeps): Ta detector, workerState, queue, + gate: deps.gate, searchTasks: async (q) => { const { tasks } = await searchTasks(q); routables.clear(); @@ -322,6 +328,57 @@ export interface RunWorkspaceWorkerOptions { cliVersion?: string; } +function formatClockTime(at: number): string { + return new Date(at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +/** + * Startup banner for working windows (quiet hours): what the windows are, + * whether pickup is currently allowed, and when the next flip happens. + */ +export function describePickupSchedule(gate: PickupGate): void { + const snapshot = gate.snapshot(); + if (!snapshot.enabled) { + return; + } + const rules = [ + ...snapshot.active.map((spec) => `active ${spec}`), + ...snapshot.blocked.map((spec) => `blocked ${spec}`), + ].join(", "); + console.log(`🕒 Working windows (${snapshot.timezone}): ${rules}`); + const next = snapshot.nextChange; + if (snapshot.pickupAllowed) { + console.log( + next + ? ` New-task pickup is open now; it closes at ${formatClockTime(next.at)}.` + : " New-task pickup is open.", + ); + } else { + console.log( + next + ? `🌙 New-task pickup is paused until ${formatClockTime(next.at)} — in-flight tasks finish normally; \`devintern worker run-now\` drains immediately.` + : "🌙 New-task pickup is paused — `devintern worker run-now` drains immediately.", + ); + } +} + +/** Log working-window flips exactly once per change (driven by poll ticks). */ +export function attachPickupScheduleLogger(gate: PickupGate): void { + gate.onChange((snapshot: ScheduleSnapshot) => { + if (snapshot.pickupAllowed) { + console.log( + `☀️ [schedule] working window opened (${snapshot.active.join(", ")}, ${snapshot.timezone}); new-task pickup resumed`, + ); + } else { + const next = snapshot.nextChange; + const until = next ? ` until ${formatClockTime(next.at)}` : ""; + console.log( + `🌙 [schedule] outside the working window${until}; no new tracker tasks are picked up (in-flight tasks continue, other activity is unaffected)`, + ); + } + }); +} + /** * Assemble and start the worker in workspace (fleet) mode. * @@ -367,6 +424,14 @@ export async function runWorkspaceWorker(options: RunWorkspaceWorkerOptions): Pr const state = openWorkspaceState(workspaceDir); const repoManager = new RepoManager(workspaceDir); + // Working windows (quiet hours): gate only the ready-task drain; reviews, + // mentions, automations, and relay events stay on their normal paths. + const pickupGate = createPickupGate(config.worker.schedule, { + runNowPath: workspaceRunNowPath(workspaceDir), + }); + describePickupSchedule(pickupGate); + attachPickupScheduleLogger(pickupGate); + for (const repo of config.repos) { const removed = await repoManager.sweepStaleWorktrees( repo.name, @@ -429,6 +494,7 @@ export async function runWorkspaceWorker(options: RunWorkspaceWorkerOptions): Pr searchTasks: (q) => tracker.searchTasks(q), query, intervalSeconds, + gate: pickupGate, verbose: options.verbose, }), ); @@ -449,7 +515,10 @@ export async function runWorkspaceWorker(options: RunWorkspaceWorkerOptions): Pr if (config.workspace.dashboard) { try { const { startDashboardServer } = await import("../../dashboard-server"); - startDashboardServer({ port: config.workspace.dashboardPort }); + startDashboardServer({ + port: config.workspace.dashboardPort, + scheduleSnapshot: () => (pickupGate.enabled ? pickupGate.snapshot() : null), + }); } catch (error) { console.warn( `⚠️ Dashboard could not start (${(error as Error).message}); the worker will continue.`, diff --git a/packages/code/tests/dashboard-api.test.ts b/packages/code/tests/dashboard-api.test.ts index a17825d..d22605f 100644 --- a/packages/code/tests/dashboard-api.test.ts +++ b/packages/code/tests/dashboard-api.test.ts @@ -300,4 +300,31 @@ describe("dashboard server", () => { server.stop(true); } }); + + test("handleWorkerStatus surfaces the working-window snapshot when provided", () => { + const snapshot = { + enabled: true, + pickupAllowed: false, + active: ["22:00-06:00"], + blocked: [], + timezone: "UTC", + catchUpMissed: true, + manualRequested: false, + nextChange: { at: Date.UTC(2026, 5, 16, 22, 0), kind: "open" as const }, + }; + const scheduled = new DashboardData({ + dbPath, + workingDir: dir, + scheduleSnapshot: () => snapshot, + }); + const response = handleWorkerStatus(scheduled); + scheduled.close(); + expect((response.body as { schedule: unknown }).schedule).toEqual(snapshot); + + // Without a provider (standalone dashboard), the field is null. + const plain = new DashboardData({ dbPath, workingDir: dir }); + const bare = handleWorkerStatus(plain); + plain.close(); + expect((bare.body as { schedule: unknown }).schedule).toBeNull(); + }); }); diff --git a/packages/code/tests/schedule.test.ts b/packages/code/tests/schedule.test.ts new file mode 100644 index 0000000..b3a3f07 --- /dev/null +++ b/packages/code/tests/schedule.test.ts @@ -0,0 +1,453 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +import { + DEFAULT_CATCH_UP_MISSED, + WallClock, + createPickupGate, + formatTimeWindow, + lastElapsedActiveWindow, + missedMostRecentWindow, + nextTransition, + parseTimeWindowSpec, + parseWorkerScheduleSection, + pickupAllowedAt, +} from "../src/lib/schedule"; +import type { + ParsedTimeWindow, + ScheduleTransition, + WorkerScheduleConfig, +} from "../src/lib/schedule"; + +function windowSpec(spec: string): ParsedTimeWindow { + return parseTimeWindowSpec(spec); +} + +function configOf( + active: string[], + blocked: string[] = [], + timezone?: string, +): WorkerScheduleConfig { + const parsed = parseWorkerScheduleSection({ + active, + blocked, + ...(timezone ? { timezone } : {}), + }); + expect(parsed.errors).toEqual([]); + expect(parsed.config).not.toBeNull(); + return parsed.config as WorkerScheduleConfig; +} + +/** UTC midnight anchors keep every expectation below independent of the host clock. */ + +describe("parseTimeWindowSpec", () => { + test("parses plain and overnight windows", () => { + const day = windowSpec("09:00-17:00"); + expect(day.startMinutes).toBe(540); + expect(day.endMinutes).toBe(1020); + expect(day.spec).toBe("09:00-17:00"); + + const night = windowSpec("22:00-06:00"); + expect(night.startMinutes).toBe(22 * 60); + expect(night.endMinutes).toBe(6 * 60); + + const padded = windowSpec(" 7:05 - 8:10 "); + expect(padded.spec).toBe("7:05 - 8:10".trim()); + expect(padded.startMinutes).toBe(425); + }); + + test("rejects malformed specs with actionable errors", () => { + for (const bad of ["9-17", "24:00-02:00", "10:60-12:00", "ab:cd-ef:gh", "0900-1700"]) { + expect(() => windowSpec(bad)).toThrow(/time window|invalid/); + } + }); + + test("rejects zero-length windows", () => { + expect(() => windowSpec("10:00-10:00")).toThrow(/same time/); + }); + + test("formatTimeWindow canonicalizes to HH:MM-HH:MM", () => { + expect(formatTimeWindow(windowSpec("7:05-8:10"))).toBe("07:05-08:10"); + expect(formatTimeWindow(windowSpec("22:00-06:00"))).toBe("22:00-06:00"); + }); +}); + +describe("parseWorkerScheduleSection", () => { + test("absent or empty section disables the schedule", () => { + expect(parseWorkerScheduleSection(undefined).config).toBeNull(); + expect(parseWorkerScheduleSection(null).errors).toEqual([]); + expect(parseWorkerScheduleSection({}).config).toBeNull(); + // Only the catch-up flag present says nothing about windows. + expect(parseWorkerScheduleSection({ catch_up_missed: true }).config).toBeNull(); + }); + + test("parses active/blocked windows and options", () => { + const { config, errors } = parseWorkerScheduleSection({ + active: ["22:00-06:00", "12:00-13:00"], + blocked: ["01:00-02:00"], + timezone: "Europe/Berlin", + catch_up_missed: false, + }); + expect(errors).toEqual([]); + expect(config?.active.map((w) => w.spec)).toEqual(["22:00-06:00", "12:00-13:00"]); + expect(config?.blocked.map((w) => w.spec)).toEqual(["01:00-02:00"]); + expect(config?.timezone).toBe("Europe/Berlin"); + expect(config?.catchUpMissed).toBe(false); + }); + + test("defaults catch_up_missed to true and leaves blank timezone machine-local", () => { + const { config } = parseWorkerScheduleSection({ active: ["09:00-17:00"], timezone: "" }); + expect(config?.catchUpMissed).toBe(DEFAULT_CATCH_UP_MISSED); + expect(config?.timezone).toBeUndefined(); + }); + + test("collects all problems in one pass", () => { + const { config, errors } = parseWorkerScheduleSection({ + active: ["nope", "25:00-26:00"], + blocked: ["ok-but-equal:0-0"], + timezone: "Mars/Olympus_Mons", + catch_up_missed: "yes", + }); + expect(config).toBeNull(); + expect(errors.length).toBeGreaterThanOrEqual(5); + expect(errors.some((e) => e.includes("[worker.schedule].active"))).toBe(true); + expect(errors.some((e) => e.includes("timezone"))).toBe(true); + expect(errors.some((e) => e.includes("catch_up_missed"))).toBe(true); + }); + + test("non-array window lists are rejected", () => { + const { errors } = parseWorkerScheduleSection({ active: "22:00-06:00" }); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("must be an array"); + }); + + test("a non-table section is an error", () => { + const { errors } = parseWorkerScheduleSection(["22:00-06:00"]); + expect(errors).toHaveLength(1); + }); +}); + +describe("pickupAllowedAt (pure minute logic)", () => { + test("overnight wrap covers both sides of midnight", () => { + const schedule = configOf(["22:00-06:00"]); + expect(pickupAllowedAt(schedule, 23 * 60)).toBe(true); // 23:00 + expect(pickupAllowedAt(schedule, 3 * 60)).toBe(true); // 03:00 + expect(pickupAllowedAt(schedule, 6 * 60)).toBe(false); // 06:00 = end (exclusive) + expect(pickupAllowedAt(schedule, 21 * 60 + 59)).toBe(false); + expect(pickupAllowedAt(schedule, 22 * 60)).toBe(true); + expect(pickupAllowedAt(schedule, 0)).toBe(true); // exactly midnight + }); + + test("multiple active windows union; blocked always wins on overlap", () => { + const schedule = configOf(["08:00-14:00", "18:00-20:00"], ["11:00-13:00"]); + expect(pickupAllowedAt(schedule, 8 * 60)).toBe(true); + expect(pickupAllowedAt(schedule, 17 * 60)).toBe(false); + expect(pickupAllowedAt(schedule, 19 * 60)).toBe(true); + expect(pickupAllowedAt(schedule, 10 * 60)).toBe(true); + expect(pickupAllowedAt(schedule, 12 * 60)).toBe(false); // blocked + expect(pickupAllowedAt(schedule, 13 * 60)).toBe(true); // after blocked ends + }); + + test("blocked without active restricts only those minutes", () => { + const schedule = configOf([], ["12:00-13:00"]); + expect(pickupAllowedAt(schedule, 11 * 60)).toBe(true); + expect(pickupAllowedAt(schedule, 12 * 60 + 30)).toBe(false); + expect(pickupAllowedAt(schedule, 13 * 60)).toBe(true); + }); +}); + +describe("WallClock", () => { + test("minutesOfDay reads the configured zone", () => { + const utc = new WallClock("UTC"); + expect(utc.minutesOfDay(Date.UTC(2026, 5, 15, 8, 30))).toBe(510); + + const ny = new WallClock("America/New_York"); // EDT, UTC-4 in June + expect(ny.minutesOfDay(Date.UTC(2026, 5, 15, 8, 30))).toBe(270); // 04:30 + + const system = new WallClock(); + const now = new Date(); + expect(system.minutesOfDay(now.getTime())).toBe(now.getHours() * 60 + now.getMinutes()); + }); + + test("instantAt round-trips through wallAt on ordinary days", () => { + const ny = new WallClock("America/New_York"); + // Any instant during NY's local June 15 works as the day anchor. + const localDayInstant = Date.UTC(2026, 5, 15, 17); // 13:00 EDT + const instant = ny.instantAt(localDayInstant, 22 * 60); + const wall = ny.wallAt(instant); + expect([wall.year, wall.month, wall.day]).toEqual([2026, 6, 15]); + expect(wall.hour * 60 + wall.minute).toBe(22 * 60); + // New York is UTC-4 in June. + expect(instant).toBe(Date.UTC(2026, 5, 16, 2, 0)); + }); + + test("instantAt across a DST fall-back resolves inside standard time", () => { + // Nov 1 2026: US clocks repeat the 01:00 hour (EDT -> EST at 06:00Z). + const ny = new WallClock("America/New_York"); + const localDayInstant = Date.UTC(2026, 10, 1, 12); // 08:00 EDT Nov 1 + const instant = ny.instantAt(localDayInstant, 90); + const wall = ny.wallAt(instant); + expect([wall.month, wall.day]).toEqual([11, 1]); + expect(wall.hour * 60 + wall.minute).toBe(90); + expect(instant).toBe(Date.UTC(2026, 10, 1, 6, 30)); // second pass, EST (UTC-5) + }); + + test("instantAt snaps forward across a spring-forward gap", () => { + // Mar 8 2026: US clocks jump 02:00 -> 03:00 local (07:00Z). + const ny = new WallClock("America/New_York"); + const localDayInstant = Date.UTC(2026, 2, 8, 12); // 08:00 EDT Mar 8 + const instant = ny.instantAt(localDayInstant, 150); // 02:30 does not exist + const wall = ny.wallAt(instant); + expect(wall.hour * 60 + wall.minute).toBeGreaterThanOrEqual(150); // never before schedule + expect(instant).toBeLessThanOrEqual(Date.UTC(2026, 2, 8, 8, 0)); // within ~an hour + }); +}); + +function collectFlips( + schedule: WorkerScheduleConfig, + zone: string, + dayStartMs: number, + spanMs = 86_400_000, +): ScheduleTransition[] { + const clock = new WallClock(zone); + const flips: ScheduleTransition[] = []; + let cursor = dayStartMs; + for (let i = 0; i < 12; i++) { + const transition = nextTransition(schedule, clock, cursor); + if (!transition || transition.at >= dayStartMs + spanMs) break; + flips.push(transition); + cursor = transition.at; + } + return flips; +} + +describe("nextTransition", () => { + const zone = "UTC"; + + test("finds open and close for a daytime window", () => { + const schedule = configOf(["09:00-17:00"], [], zone); + const fromBefore = Date.UTC(2026, 5, 15, 8, 0); + expect(nextTransition(schedule, new WallClock(zone), fromBefore)).toEqual({ + at: Date.UTC(2026, 5, 15, 9, 0), + kind: "open", + }); + + const fromInside = Date.UTC(2026, 5, 15, 12, 0); + expect(nextTransition(schedule, new WallClock(zone), fromInside)).toEqual({ + at: Date.UTC(2026, 5, 15, 17, 0), + kind: "close", + }); + + const fromEvening = Date.UTC(2026, 5, 15, 18, 0); + expect(nextTransition(schedule, new WallClock(zone), fromEvening)).toEqual({ + at: Date.UTC(2026, 5, 16, 9, 0), + kind: "open", + }); + }); + + test("handles a window crossing midnight", () => { + const schedule = configOf(["22:00-06:00"], [], zone); + const clock = new WallClock(zone); + + // Just before midnight: still inside tonight's window. + const lateNight = Date.UTC(2026, 5, 16, 23, 30); + expect(nextTransition(schedule, clock, lateNight)).toEqual({ + at: Date.UTC(2026, 5, 17, 6, 0), + kind: "close", + }); + + // Early morning: close is imminent today. + const earlyMorning = Date.UTC(2026, 5, 16, 5, 0); + expect(nextTransition(schedule, clock, earlyMorning)).toEqual({ + at: Date.UTC(2026, 5, 16, 6, 0), + kind: "close", + }); + + // Afternoon: opens again this evening. + const afternoon = Date.UTC(2026, 5, 16, 12, 0); + expect(nextTransition(schedule, clock, afternoon)).toEqual({ + at: Date.UTC(2026, 5, 16, 22, 0), + kind: "open", + }); + }); + + test("blocked overlap suppresses availability between its edges", () => { + const schedule = configOf(["08:00-14:00"], ["11:00-13:00"], zone); + const flips = collectFlips(schedule, zone, Date.UTC(2026, 5, 15)); + expect(flips.map((f) => [f.kind, f.at])).toEqual([ + ["open", Date.UTC(2026, 5, 15, 8, 0)], + ["close", Date.UTC(2026, 5, 15, 11, 0)], + ["open", Date.UTC(2026, 5, 15, 13, 0)], + ["close", Date.UTC(2026, 5, 15, 14, 0)], + ]); + }); + + test("a US spring-forward day produces clean open/close flips", () => { + const zone = "America/New_York"; // Mar 8 2026: 02:00 -> 03:00 local + const schedule = configOf(["01:00-04:00"], [], zone); + const flips = collectFlips(schedule, zone, Date.UTC(2026, 2, 8, 5)); + expect(flips.map((f) => f.kind)).toEqual(["open", "close"]); + + // The 02:30–03:30 stretch is shortened by the lost hour: window closes + // one wall-clock hour earlier than the naive count suggests. + const closeAtEdt = new WallClock(zone).wallAt(flips[1]!.at); + expect(closeAtEdt.hour).toBe(4); + }); + + test("a US fall-back day resolves the repeated hour to its second pass", () => { + const zone = "America/New_York"; // Nov 1 2026: 02:00 -> 01:00 local + const schedule = configOf(["01:20-01:40"], [], zone); + const flips = collectFlips(schedule, zone, Date.UTC(2026, 10, 1, 4)); + // Runtime pickup decisions read wall minutes per tick, so the repeated + // hour works; the enumerated transition display pins to the + // standard-time (second) occurrence documented in `instantAt`. + expect(flips.map((f) => f.kind)).toEqual(["open", "close"]); + const clock = new WallClock(zone); + const openWall = clock.wallAt(flips[0]!.at); + const closeWall = clock.wallAt(flips[1]!.at); + expect(openWall.hour * 60 + openWall.minute).toBe(80); + expect(closeWall.hour * 60 + closeWall.minute).toBe(100); + }); +}); + +describe("missedMostRecentWindow / lastElapsedActiveWindow", () => { + const zone = "UTC"; + const schedule = configOf(["22:00-06:00"], [], zone); + const clock = new WallClock(zone); + + test("draining during the last elapsed window means nothing was missed", () => { + const now = Date.UTC(2026, 5, 15, 9, 0); // morning after the window ended 06:00Z + const drainedDuringWindow = Date.UTC(2026, 5, 14, 23, 0); // inside Jun 14 22:00 -> Jun 15 06:00Z + expect(missedMostRecentWindow(schedule, clock, drainedDuringWindow, now)).toBe(false); + }); + + test("the most recent window counts even though earlier ones also elapsed", () => { + const now = Date.UTC(2026, 5, 15, 9, 0); + const startOfLatestWindow = Date.UTC(2026, 5, 14, 22, 0); + // A drain from two nights ago predates the latest window's start. + const staleDrain = Date.UTC(2026, 5, 13, 23, 0); + expect(missedMostRecentWindow(schedule, clock, staleDrain, now)).toBe(true); + + const lastWindow = lastElapsedActiveWindow(schedule, clock, now); + expect(lastWindow?.startedAt).toBe(startOfLatestWindow); + expect(lastWindow?.endedAt).toBe(Date.UTC(2026, 5, 15, 6, 0)); + }); + + test("never-drained state counts as missed", () => { + const now = Date.UTC(2026, 5, 15, 9, 0); + expect(missedMostRecentWindow(schedule, clock, null, now)).toBe(true); + }); + + test("inside an open window nothing is 'missed'", () => { + const now = Date.UTC(2026, 5, 15, 23, 0); // window is open right now + expect(missedMostRecentWindow(schedule, clock, null, now)).toBe(false); + }); + + test("blocked-only schedules never report catch-up", () => { + const blockedOnly = configOf([], ["12:00-13:00"], zone); + const now = Date.UTC(2026, 5, 15, 9, 0); + expect(missedMostRecentWindow(blockedOnly, clock, null, now)).toBe(false); + }); + + test("catch_up_missed=false disables startup catch-up in the gate", () => { + const parsed = parseWorkerScheduleSection({ + active: ["22:00-06:00"], + timezone: "UTC", + catch_up_missed: false, + }); + expect(parsed.errors).toEqual([]); + const gate = createPickupGate(parsed.config, { now: () => Date.UTC(2026, 5, 15, 9, 0) }); + expect(gate.shouldCatchUpOnStart(null)).toBe(false); + }); +}); + +describe("PickupGate", () => { + function uniqueFile(prefix: string): string { + return join(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`); + } + + let runNowPath: string; + + beforeEach(() => { + runNowPath = uniqueFile("run-now-test"); + }); + + afterEach(() => { + rmSync(runNowPath, { force: true }); + }); + + test("allows and blocks by minute of day, reporting flips once per change", () => { + const utcNow = (isoUtc: string) => Date.parse(isoUtc); + let now = utcNow("2026-06-15T11:00:00Z"); + const gate = createPickupGate(configOf(["10:00-12:00"], [], "UTC"), { now: () => now }); + + expect(gate.enabled).toBe(true); + expect(gate.pickupAllowed()).toBe(true); + + const events: boolean[] = []; + gate.onChange((snapshot) => events.push(snapshot.pickupAllowed)); + + now = utcNow("2026-06-15T12:30:00Z"); + expect(gate.pickupAllowed()).toBe(false); + now = utcNow("2026-06-15T12:45:00Z"); + expect(gate.pickupAllowed()).toBe(false); // same closed state: no extra event + now = utcNow("2026-06-15T13:00:00Z"); + expect(gate.pickupAllowed()).toBe(false); // outside any window + now = utcNow("2026-06-16T10:30:00Z"); + expect(gate.pickupAllowed()).toBe(true); + expect(events).toEqual([false, true]); + }); + + test("consumeManualPickup consumes the sentinel file exactly once", () => { + const gate = createPickupGate(configOf(["10:00-12:00"], [], "UTC"), { + now: () => Date.parse("2026-06-15T23:00:00Z"), // outside the window + runNowPath, + }); + expect(gate.pickupAllowed()).toBe(false); + writeFileSync(runNowPath, ""); + expect(gate.pickupAllowed()).toBe(true); // manual request forces the gate open + expect(gate.consumeManualPickup()).toBe(true); + expect(gate.consumeManualPickup()).toBe(false); + expect(gate.pickupAllowed()).toBe(false); + }); + + test("without a sentinel path there is no manual override", () => { + const gate = createPickupGate(configOf(["10:00-12:00"], [], "UTC"), { runNowPath: undefined }); + expect(gate.consumeManualPickup()).toBe(false); + }); + + test("snapshot carries windows, resolved zone, and the next change", () => { + const gate = createPickupGate(configOf(["10:00-12:00"], ["11:00-11:30"], "UTC"), { + now: () => Date.parse("2026-06-15T10:15:00Z"), + runNowPath, + }); + const snapshot = gate.snapshot(); + expect(snapshot.enabled).toBe(true); + expect(snapshot.pickupAllowed).toBe(true); + expect(snapshot.active).toEqual(["10:00-12:00"]); + expect(snapshot.blocked).toEqual(["11:00-11:30"]); + expect(snapshot.timezone).toBe("UTC"); + expect(snapshot.manualRequested).toBe(false); + expect(snapshot.nextChange?.kind).toBe("close"); + expect(snapshot.nextChange?.at).toBe(Date.parse("2026-06-15T11:00:00Z")); + }); + + test("shouldCatchUpOnStart delegates to missed-window logic", () => { + const gate = createPickupGate(configOf(["22:00-06:00"], [], "UTC"), { + now: () => Date.UTC(2026, 5, 15, 9, 0), + }); + expect(gate.shouldCatchUpOnStart(null)).toBe(true); + expect(gate.shouldCatchUpOnStart(Date.UTC(2026, 5, 14, 23, 0))).toBe(false); + expect(gate.shouldCatchUpOnStart(Date.UTC(2026, 5, 13, 23, 0))).toBe(true); + }); + + test("createPickupGate(null) yields an always-open disabled gate", () => { + const gate = createPickupGate(null); + expect(gate.enabled).toBe(false); + expect(gate.pickupAllowed()).toBe(true); + expect(gate.shouldCatchUpOnStart(null)).toBe(false); + expect(gate.snapshot().enabled).toBe(false); + }); +}); diff --git a/packages/code/tests/task-polling-acquirer.test.ts b/packages/code/tests/task-polling-acquirer.test.ts index 4319b9e..4c6c8f1 100644 --- a/packages/code/tests/task-polling-acquirer.test.ts +++ b/packages/code/tests/task-polling-acquirer.test.ts @@ -6,8 +6,9 @@ import { tmpdir } from "os"; import { createMarkdownChangeDetector } from "../src/lib/change-detector"; import { processedTaskId, TaskPollingAcquirer } from "../src/lib/task-polling-acquirer"; import type { ReadyTask } from "../src/lib/task-polling-acquirer"; +import type { PickupGate } from "../src/lib/schedule"; +import { TASK_POLL_LAST_DRAIN_KEY, WorkerState } from "../src/lib/worker-state"; import { WebhookQueue } from "../src/lib/webhook-queue"; -import { WorkerState } from "../src/lib/worker-state"; function uniqueDir(prefix: string): string { return join(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`); @@ -109,6 +110,7 @@ describe("TaskPollingAcquirer", () => { tasks: ReadyTask[]; executed: string[]; executeResult?: boolean; + gate?: PickupGate; }) { let call = 0; const seenCursors: (string | null)[] = []; @@ -116,6 +118,7 @@ describe("TaskPollingAcquirer", () => { trackerType: "markdown", query: "status=todo", intervalSeconds: 60, + gate: options.gate, detector: { source: "markdown", async changesSince(cursor) { @@ -377,4 +380,124 @@ describe("TaskPollingAcquirer", () => { await acquirer.tick(); // must not throw expect(workerState.getCursor("markdown")?.cursorValue).toBe("42"); }); + + describe("working-window gating", () => { + function stubGate(overrides: Partial = {}): PickupGate { + return { + enabled: true, + pickupAllowed: () => false, // closed by default + consumeManualPickup: () => false, + shouldCatchUpOnStart: () => false, + snapshot: () => { + throw new Error("unused in acquirer tests"); + }, + onChange: () => {}, + ...overrides, + }; + } + + test("a closed gate skips detection, evaluation, and execution entirely", async () => { + const executed: string[] = []; + const { acquirer, seenCursors } = makeAcquirer({ + detectorResults: [{ changed: true, nextCursor: "100" }], + tasks: [{ key: "TASK-1", updated: "a" }], + executed, + gate: stubGate(), + }); + + await acquirer.tick(); + expect(executed).toEqual([]); + expect(seenCursors).toEqual([]); // no detection while gated out + expect(workerState.getCursor("markdown")).toBeNull(); + }); + + test("a manual run-now request opens the gate for exactly one tick", async () => { + const executed: string[] = []; + let pendingManual = false; + const { acquirer } = makeAcquirer({ + detectorResults: [ + { changed: true, nextCursor: "100" }, + { changed: true, nextCursor: "200" }, + ], + tasks: [{ key: "TASK-1", updated: "a" }], + executed, + gate: stubGate({ + consumeManualPickup: () => { + const had = pendingManual; + pendingManual = false; + return had; + }, + }), + }); + // Simulate `devintern worker run-now`: the sentinel appears before tick 1. + pendingManual = true; + + await acquirer.tick(); + expect(executed).toEqual(["TASK-1"]); + expect(workerState.getCursor("markdown")?.cursorValue).toBe("100"); + + await acquirer.tick(); // request already consumed; window still closed + expect(executed).toEqual(["TASK-1"]); + }); + + test("an executed drain records its timestamp for catch-up bookkeeping", async () => { + const executed: string[] = []; + const { acquirer } = makeAcquirer({ + detectorResults: [{ changed: true, nextCursor: "100" }], + tasks: [], + executed, + }); + + expect(workerState.getMeta(TASK_POLL_LAST_DRAIN_KEY)).toBeNull(); + await acquirer.tick(); + expect(Number(workerState.getMeta(TASK_POLL_LAST_DRAIN_KEY))).toBeGreaterThan(0); + }); + + test("gated-out ticks do not refresh the drain timestamp", async () => { + const executed: string[] = []; + workerState.setMeta(TASK_POLL_LAST_DRAIN_KEY, String(Date.parse("2026-06-15T00:00:00Z"))); + const { acquirer } = makeAcquirer({ + detectorResults: [{ changed: true, nextCursor: "100" }], + tasks: [], + executed, + gate: stubGate(), + }); + + await acquirer.tick(); + expect(workerState.getMeta(TASK_POLL_LAST_DRAIN_KEY)).toBe( + String(Date.parse("2026-06-15T00:00:00Z")), + ); + }); + + test("start() performs one bypassing catch-up drain when the gate says a window was missed", async () => { + const executed: string[] = []; + const { acquirer } = makeAcquirer({ + detectorResults: [{ changed: true, nextCursor: "100" }], + tasks: [{ key: "TASK-9", updated: "z" }], + executed, + gate: stubGate({ shouldCatchUpOnStart: () => true }), + }); + + await acquirer.start(); + acquirer.stop(); + expect(executed).toEqual(["TASK-9"]); + }); + + test("a broken gate cannot break polling", async () => { + const executed: string[] = []; + const { acquirer } = makeAcquirer({ + detectorResults: [{ changed: true, nextCursor: "100" }], + tasks: [{ key: "TASK-1", updated: "a" }], + executed, + gate: stubGate({ + pickupAllowed: () => { + throw new Error("clock exploded"); + }, + }), + }); + + await acquirer.tick(); // failure degrades to open (fail-safe) + warn + expect(executed).toEqual(["TASK-1"]); + }); + }); }); diff --git a/packages/code/tests/workspace-config.test.ts b/packages/code/tests/workspace-config.test.ts index b628d66..cc1a863 100644 --- a/packages/code/tests/workspace-config.test.ts +++ b/packages/code/tests/workspace-config.test.ts @@ -254,6 +254,71 @@ repo = "missing" }); }); +describe("parseWorkspaceConfig [worker.schedule] (quiet hours)", () => { + test("parses working windows into config.worker.schedule", () => { + const config = parseWorkspaceConfig(` +[defaults] +tracker = "jira" + +[worker.schedule] +active = ["22:00-06:00", "12:00-13:00"] +blocked = ["01:30-02:30"] +timezone = "Europe/Berlin" +catch_up_missed = false + +[[repos]] +name = "backend" +remote = "git@github.com:acme/a.git" +`); + + expect(config.worker.schedule?.active.map((w) => w.spec)).toEqual([ + "22:00-06:00", + "12:00-13:00", + ]); + expect(config.worker.schedule?.blocked.map((w) => w.spec)).toEqual(["01:30-02:30"]); + expect(config.worker.schedule?.timezone).toBe("Europe/Berlin"); + expect(config.worker.schedule?.catchUpMissed).toBe(false); + }); + + test("no [worker] section leaves the schedule disabled", () => { + const config = parseWorkspaceConfig(VALID_CONFIG); + expect(config.worker.schedule).toBeNull(); + }); + + test("an empty [worker.schedule] section stays disabled", () => { + const config = parseWorkspaceConfig(` +[defaults] +tracker = "jira" + +[worker.schedule] + +[[repos]] +name = "backend" +remote = "git@github.com:acme/a.git" +`); + expect(config.worker.schedule).toBeNull(); + }); + + test("schedule problems are collected alongside other errors", () => { + let message = ""; + try { + parseWorkspaceConfig(` +[defaults] +tracker = "" + +[worker.schedule] +active = ["25:99-06:00"] +timezone = "Nowhere/Land" +`); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + expect(message).toMatch(/\[worker\.schedule\]\.active/); + expect(message).toMatch(/is not a valid IANA timezone/); + expect(message).toMatch(/tracker is required/); + }); +}); + describe("workspace paths", () => { let workspaceDir: string; let previousOverride: string | undefined; diff --git a/packages/code/tests/workspace-state.test.ts b/packages/code/tests/workspace-state.test.ts index 06f59a8..6726212 100644 --- a/packages/code/tests/workspace-state.test.ts +++ b/packages/code/tests/workspace-state.test.ts @@ -101,4 +101,20 @@ describe("workspace state", () => { frontend.release(); expect(createRepoRunLock("backend", workspaceDir).acquire().success).toBe(true); }); + + test("worker meta round-trips and upserts (task-drain catch-up bookkeeping)", () => { + const state = openWorkspaceState(workspaceDir); + try { + expect(state.workerState.getMeta("task-poll:last-drain-at")).toBeNull(); + state.workerState.setMeta("task-poll:last-drain-at", "1000"); + state.workerState.setMeta("task-poll:last-drain-at", "2000"); + state.workerState.setMeta("other-key", "x"); + + expect(state.workerState.getMeta("task-poll:last-drain-at")).toBe("2000"); + expect(state.workerState.getMeta("other-key")).toBe("x"); + expect(state.workerState.getMeta("missing")).toBeNull(); + } finally { + state.close(); + } + }); }); diff --git a/packages/dashboard-ui/src/components/StatusStrip.tsx b/packages/dashboard-ui/src/components/StatusStrip.tsx index ed45bee..989feec 100644 --- a/packages/dashboard-ui/src/components/StatusStrip.tsx +++ b/packages/dashboard-ui/src/components/StatusStrip.tsx @@ -1,9 +1,28 @@ -import { Activity, CircleCheck, CircleOff, GitPullRequest, Inbox } from "lucide-react"; +import { Activity, CircleCheck, CircleOff, Clock, GitPullRequest, Inbox } from "lucide-react"; import { usePoll } from "@/lib/api"; -import type { WorkerResponse } from "@/lib/api"; +import type { ScheduleSnapshot, WorkerResponse } from "@/lib/api"; import { cn } from "@/lib/utils"; +function formatClockTime(at: number): string { + return new Date(at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +function scheduleLabel(schedule: ScheduleSnapshot): string { + const windows = schedule.active.length > 0 ? schedule.active.join(", ") : "unrestricted"; + if (schedule.manualRequested) { + return `manual run requested (${windows})`; + } + if (schedule.pickupAllowed) { + return schedule.nextChange + ? `working window ${windows} — closes ${formatClockTime(schedule.nextChange.at)}` + : `working window ${windows}`; + } + return schedule.nextChange + ? `outside working window ${windows} — opens ${formatClockTime(schedule.nextChange.at)}` + : `outside working window (${windows})`; +} + function Item({ icon, label, @@ -47,6 +66,16 @@ export function StatusStrip() { label={running ? `worker running (pid ${data.worker?.pid})` : "worker stopped"} className={running ? "text-foreground" : undefined} /> + {data.schedule?.enabled ? ( + + } + label={scheduleLabel(data.schedule)} + /> + ) : null} } label={queueLabel} /> } diff --git a/packages/dashboard-ui/src/lib/api.ts b/packages/dashboard-ui/src/lib/api.ts index 9600f11..d0dfaf9 100644 --- a/packages/dashboard-ui/src/lib/api.ts +++ b/packages/dashboard-ui/src/lib/api.ts @@ -83,10 +83,23 @@ export interface StatsResponse { } | null; } +/** Working-window (quiet hours) status from the worker process. */ +export interface ScheduleSnapshot { + enabled: boolean; + pickupAllowed: boolean; + active: string[]; + blocked: string[]; + timezone: string; + catchUpMissed: boolean; + manualRequested: boolean; + nextChange?: { at: number; kind: "open" | "close" }; +} + export interface WorkerResponse { worker: { running: boolean; pid?: number; startedAt?: string } | null; queue: { pending: number; processing: number; failed: number }; agentPrs: { open: number; closed: number }; + schedule?: ScheduleSnapshot | null; cursors: { source: string; cursorValue: string; updatedAt: number }[]; dbPath: string; dbMissing: boolean;