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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/code/story-points-estimation.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ query = "sprint in openSprints() AND \"Story Points\" is EMPTY"

Each entry needs a unique `id`, boolean `enabled`, a non-empty `query`, and exactly one of `cron` or `interval`. There is no `prompt` and no `repo`: a due entry runs one-shot `devintern --estimate --query "<query>"`, which estimates — never implements, branches, or opens PRs. Estimating does not depend on `[defaults].task_query`; an omitted or empty `[[estimations]]` table simply means estimation is off.

You can add, remove, enable, disable, reschedule, or change the query of an estimation entry while the worker is running. The worker validates and reconciles the edit automatically without interrupting an in-progress sweep.

Notes:

- Works with Jira, Linear, Azure DevOps, Asana, and GitHub (comment-only). Trello/markdown workspaces fail at startup.
Expand Down
8 changes: 5 additions & 3 deletions docs/code/worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Do not change production code."""

Every entry needs a stable unique `id`, boolean `enabled`, non-empty `prompt`, and exactly one schedule. Intervals use positive minutes, hours, or days (`15m`, `6h`, `1d`). Cron expressions have five fields and use the worker host's timezone in v1; persisted occurrence times are UTC.

Configuration is validated as a group at worker startup and changes require a restart. Automations are a valid event source, so `devintern worker` stays running without a task query when at least one automation entry is configured (disabled entries are validated but not scheduled).
Configuration is validated on load; while the worker runs it revalidates edits to `workspace.toml` automatically (SIGHUP forces a reload) — see [Workspaces → Editing workspace.toml while running](./workspaces.md#editing-workspace.toml-while-running). Automations are a valid event source, so `devintern worker` stays running without a task query when at least one automation entry is configured (disabled entries are validated but not scheduled).

### What an automation is

Expand Down Expand Up @@ -101,7 +101,7 @@ On shutdown the scheduler stops its timer, terminates active automation subproce

| Symptom | Likely cause |
| ---------------------------- | ------------------------------------------------------------ |
| No occurrences fire after editing the TOML | Config is loaded at startup — restart the worker. Startup validation errors name the offending entry. |
| No occurrences fire after editing the TOML | Check the worker log: the reload logs validation errors naming the offending entry, and changing a schedule resets its cursor (the next run is the next scheduled time, not immediately). |
| `occurrence skipped: previous run is active` | The previous occurrence still runs (or its lease is stale). Long prompts may simply need a longer schedule. |
| `occurrence skipped: repository is busy` | Another task holds the repo run lock; the next occurrence will retry. |
| Scheduled runs missing from the dashboard | Filter the run list by origin `scheduled`; check the worker has an automation license (startup log). |
Expand Down Expand Up @@ -129,6 +129,8 @@ query = "sprint in openSprints() AND \"Story Points\" is EMPTY"

Each entry needs a unique `id`, boolean `enabled`, non-empty `query`, and exactly one of `cron` or `interval`. Omitting the table (or leaving every entry disabled) changes nothing: estimation is simply off, and `[defaults].task_query` is never estimated as a side effect. The workspace tracker must support estimation (Jira, Linear, Azure DevOps, Asana, GitHub comment-only); Trello/markdown workspaces fail at startup with a clear error. `worker init` does not ask about estimations — add tables by hand.

Estimation entries live-reload with `workspace.toml`: added and re-enabled entries schedule their next future occurrence, changed schedules reset their cursor, query edits apply to the next sweep, and removed entries stop scheduling without interrupting a sweep already in progress.

When an entry comes due the worker runs one-shot `devintern --estimate --query "<query>"` from the workspace home. That path keeps all of the interactive behavior: tickets younger than 24 hours are skipped, already-estimated tickets are skipped unless the ticket changed since the estimate, changed tickets are re-estimated with the estimate comment updated in place, points are written to the tracker field, and usage-limit aborts exit cleanly so the next occurrence retries. Each sweep is recorded with its own `estimate` origin (plus the schedule id) in the [dashboard](./dashboard.md) — it never shows up as a scheduled implement run.

### Serialization
Expand Down Expand Up @@ -250,7 +252,7 @@ conflict_resolution_cron = "0 3 * * *" # or conflict_resolution_interval = "1d

In scheduled mode the poller still detects every conflict on the first tick it appears and queues it durably (a pending base-sync event), but the agent is not invoked. When the scheduled window arrives — cron uses the worker host timezone, intervals are relative — the worker resolves all queued conflicts in one pass and logs the active mode and next window at startup. The window stays open for a grace period (`WORKER_RESOLVE_WINDOW_GRACE_MINUTES`, default 60) so quiet-period waits and retry backoffs inside the pass can still complete; anything unresolved when it closes waits for the next window. A window that arrives while the worker is down (missed nightly run) catches up on the first tick after restart. Stale resolutions cannot happen: before invoking the agent the worker re-fetches the PR, and closed/merged PRs or a conflict that resolved itself are dropped from the queue without spending tokens. The manual `devintern resolve-conflicts <pr-url>` command always works on demand, and once GitHub reports a PR conflict-free its queued event never triggers an agent run.

The setting applies to the whole workspace and takes effect on worker restart, like the rest of `workspace.toml`. Between windows a conflicted PR cannot be merged, so teams that rely on instant rebases should keep `auto`. See [Workspaces → Automatic conflict resolution](./workspaces.md#automatic-conflict-resolution-auto-vs-scheduled-vs-disabled) for the config reference and tradeoffs.
The setting applies to the whole workspace and live-reloads with `workspace.toml`. Between windows a conflicted PR cannot be merged, so teams that rely on instant rebases should keep `auto`. See [Workspaces → Automatic conflict resolution](./workspaces.md#automatic-conflict-resolution-auto-vs-scheduled-vs-disabled) for the config reference and tradeoffs.

To turn automatic conflict resolution off entirely — no detection, no queuing, no agent runs — set `conflict_resolution = "disabled"`: conflicted PRs stay conflicted until resolved by hand or via `devintern resolve-conflicts <pr-url>`.

Expand Down
13 changes: 11 additions & 2 deletions docs/code/workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ The schedule uses the same format as `[[automations]]`: a five-field cron expres

Two things are never delayed by scheduled mode: review feedback on the agent's PRs is addressed immediately as usual, and you can always run `devintern resolve-conflicts <pr-url>` by hand to fix one PR without waiting for the window — once GitHub reports the PR conflict-free, the queued event never triggers an agent run.

The setting is workspace-wide (per-repo overrides are not supported in v1) and, like the rest of `workspace.toml`, requires a worker restart to take effect. The tradeoff to keep in mind: between windows a conflicted PR cannot be merged, so on fast-moving branches where an instant rebase unblocks a waiting reviewer, `auto` stays the better choice. See [Worker Daemon → Merge conflicts on the agent's PRs](./worker.md#merge-conflicts-on-the-agents-prs) for how resolution itself works.
The setting is workspace-wide (per-repo overrides are not supported in v1) and live-reloads with the rest of the runtime configuration. The tradeoff to keep in mind: between windows a conflicted PR cannot be merged, so on fast-moving branches where an instant rebase unblocks a waiting reviewer, `auto` stays the better choice. See [Worker Daemon → Merge conflicts on the agent's PRs](./worker.md#merge-conflicts-on-the-agents-prs) for how resolution itself works.

Set `conflict_resolution = "disabled"` to turn automatic conflict resolution off entirely: the worker stops watching for conflicts on the agent's PRs altogether — no detection, no queuing, no agent runs. A PR that conflicts with its base simply stays conflicted until someone resolves it (by hand, or on demand via `devintern resolve-conflicts <pr-url>`). Review feedback and @mention handling are unaffected. This is a valid choice when the team prefers to rebase manually, or when the agent is not trusted to resolve conflicts in a sensitive repository.

Expand Down Expand Up @@ -150,7 +150,16 @@ 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 or estimations can omit the query and run as a schedules-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, automation, and estimation configuration is loaded at startup; restart the worker after editing it. Schedule state and leases for automations and estimations live in the central workspace database.
The fleet query comes from `[defaults].task_query`. A workspace with automations or estimations can omit the query and run as a schedules-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. Schedule state and leases for automations and estimations live in the central workspace database.

### Editing workspace.toml while running

The worker watches `workspace.toml` and reloads it automatically a moment after you save — no restart, and no missed tracker events or relay messages during the bounce:

- **Routing rules, repos, `task_query`, `[[automations]]`, `[[estimations]]`, `worker_task_args`, `poll_interval`, `worktrees_ttl_days`, and conflict-resolution mode/schedules apply to subsequent work.** Runs already in progress finish under the configuration they started with; everything picked up afterwards uses the new one. Changing a repo's `remote` updates its managed bare clone the next time that repo is prepared.
- **A broken edit never takes the daemon down.** The reload validates the file first; parse or schema errors are logged (naming the offending entries) and the last valid configuration keeps serving until you fix it. Rewriting identical content is ignored.
- **Manual fallback:** send SIGHUP (`kill -HUP <pid>`) to force an immediate reload if file watching is unavailable on your system.
- **Startup-only settings** still require a restart: tracker credentials in the workspace `.env` and `[defaults].tracker` (the tracker client and its detector are built once), plus `[workspace].dashboard` / `dashboard_port`. A reload that changes one of these settings is rejected in full, so the active config remains internally consistent.

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

Expand Down
70 changes: 62 additions & 8 deletions packages/code/src/lib/automation-acquirer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,11 @@ export interface SpawnedAutomationRun {
export interface AutomationAcquirerOptions {
automations: AutomationConfig[];
dbPath: string;
/** Per-task CLI flags; defaults to `--create-pr`. */
extraArgs?: string[];
/**
* Per-task CLI flags; defaults to `--create-pr`. May be a factory so live
* config reloads (e.g. `[defaults].worker_task_args`) apply to later runs.
*/
extraArgs?: string[] | (() => string[]);
resolveContext: (automation: AutomationConfig) => Promise<AutomationRunContext | null>;
now?: () => number;
spawnRun?: (automation: AutomationConfig, context: AutomationRunContext) => SpawnedAutomationRun;
Expand Down Expand Up @@ -127,6 +130,59 @@ export class AutomationAcquirer implements Acquirer {
}
}

/**
* Replace the automation set at runtime (live workspace.toml reload).
*
* Reconciles durable schedule state without interrupting active work:
*
* - Newly added or rescheduled entries register (a changed schedule resets
* the cursor; an unchanged one retains the prior interval anchor).
* - Entries removed from the config retire: removed-but-active runs finish
* their current occurrence naturally, then their schedule state is
* dropped. Fully removed inactive entries are unregistered immediately.
* - Entries merely disabled keep their schedule rows so re-enabling later
* keeps the anchor, matching restart semantics.
*/
applyAutomations(next: AutomationConfig[]): void {
const previous = this.options.automations;
this.options.automations = next;

const now = this.now();
for (const automation of next.filter((item) => item.enabled)) {
try {
this.store.register(
automation,
nextAutomationDue(automation, now),
this.stateId(automation),
);
} catch (error) {
console.error(
`❌ [${this.jobKind()}:${automation.id}] invalid schedule after reload: ` +
`${(error as Error).message}`,
);
}
}

for (const retired of previous.filter((item) => !next.some((n) => n.id === item.id))) {
const active = this.active.get(retired.id);
if (active) {
// Let the in-flight occurrence run to completion; drop its state only
// after it ends and only if the id was not re-added in the meantime.
void active.lifecycle
.catch(() => undefined)
.then(() => {
if (!this.options.automations.some((item) => item.id === retired.id)) {
this.store.unregister(this.stateId(retired));
}
});
} else {
this.store.unregister(this.stateId(retired));
}
}

this.scheduleNext();
}

private async runTick(): Promise<void> {
const leaseMs = this.options.leaseMs ?? LEASE_MS;
const kind = this.jobKind();
Expand Down Expand Up @@ -199,14 +255,12 @@ export class AutomationAcquirer implements Acquirer {
continue;
}
console.log(`\n⏰ [${kind}:${automation.id}] starting scheduled run`);
const extraArgs = this.options.extraArgs;
const args =
typeof extraArgs === "function" ? extraArgs() : (extraArgs ?? workerTaskArgs());
const run = this.options.spawnRun
? this.options.spawnRun(automation, context)
: defaultSpawnRun(
automation,
context,
this.options.extraArgs ?? workerTaskArgs(),
this.options.terminationGraceMs,
);
: defaultSpawnRun(automation, context, args, this.options.terminationGraceMs);
const active: ActiveAutomationRun = {
run,
lifecycle: Promise.resolve(),
Expand Down
10 changes: 10 additions & 0 deletions packages/code/src/lib/automation-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ export class AutomationStateStore {
}
}

/**
* Remove schedule state for an automation retired by a live config reload
* (removed from the config entirely). Rows for entries still present in
* the config — even disabled — are kept so re-enabling retains the
* interval anchor.
*/
unregister(automationId: string): void {
this.db.run("DELETE FROM automation_schedules WHERE automation_id = ?", [automationId]);
}

get(automationId: string): AutomationScheduleState | null {
const row = this.db
.query("SELECT * FROM automation_schedules WHERE automation_id = ?")
Expand Down
25 changes: 15 additions & 10 deletions packages/code/src/lib/estimation-acquirer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,23 +84,16 @@ export function spawnEstimationSweep(
*/
export class EstimationAcquirer extends AutomationAcquirer {
constructor(options: EstimationAcquirerOptions) {
const byId = new Map(options.estimations.map((item) => [item.id, item]));
// The scheduler's config shape requires a prompt; scheduled estimates
// always use the sweep runner below, so it stays empty and unread.
const schedules: AutomationConfig[] = options.estimations.map((item) => ({
...item,
prompt: "",
}));
const schedules = estimationSchedules(options.estimations);
super({
automations: schedules,
dbPath: options.dbPath,
name: "scheduled-estimations",
jobKind: "estimation",
stateId: (schedule) => `${STATE_PREFIX}${schedule.id}`,
resolveContext: (schedule) =>
options.resolveContext(byId.get(schedule.id) as EstimationConfig),
resolveContext: (schedule) => options.resolveContext(schedule as ScheduledEstimation),
spawnRun: (schedule, context) => {
const estimation = byId.get(schedule.id) as EstimationConfig;
const estimation = schedule as ScheduledEstimation;
return options.spawnRun
? options.spawnRun(estimation, context)
: spawnEstimationSweep(estimation, context);
Expand All @@ -115,4 +108,16 @@ export class EstimationAcquirer extends AutomationAcquirer {
clearInterval: options.clearInterval,
});
}

/** Reconcile scheduled estimation entries after a live workspace reload. */
applyEstimations(estimations: EstimationConfig[]): void {
this.applyAutomations(estimationSchedules(estimations));
}
}

type ScheduledEstimation = AutomationConfig & EstimationConfig;

/** Adapt estimation bodies to the shared durable scheduler's config shape. */
function estimationSchedules(estimations: EstimationConfig[]): ScheduledEstimation[] {
return estimations.map((item) => ({ ...item, prompt: "" }));
}
12 changes: 12 additions & 0 deletions packages/code/src/lib/mention-sweep-acquirer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export class MentionSweepAcquirer implements Acquirer {

/** Start sweeping: immediate first tick, then on the configured interval. */
async start(): Promise<void> {
if (this.timer) return;
console.log(
`🔎 Sweeping ${this.options.repo} for @mentions every ${this.options.intervalSeconds}s`,
);
Expand All @@ -156,6 +157,17 @@ export class MentionSweepAcquirer implements Acquirer {
}
}

/**
* Apply a new sweep cadence without restarting (live workspace config
* reload). Re-arms the repeating timer with the new interval.
*/
updateInterval(intervalSeconds: number): void {
this.options.intervalSeconds = intervalSeconds;
if (!this.timer) return;
clearInterval(this.timer);
this.timer = setInterval(() => void this.tick(), intervalSeconds * 1000);
}

/** One repo-wide sweep over both comment feeds. Skipped while busy. */
async tick(): Promise<void> {
if (this.busy) {
Expand Down
Loading
Loading