diff --git a/docs/code/github-integration.md b/docs/code/github-integration.md index 2491f29..5ba3492 100644 --- a/docs/code/github-integration.md +++ b/docs/code/github-integration.md @@ -433,7 +433,7 @@ export WEBHOOK_DEBUG="true" # Verbose request/processing logging ### Start the Server -`devintern webhook serve` runs the advanced repo-local webhook listener. Keep it separate from the workspace worker so webhook delivery and tracker automation can be operated independently. The old `devintern worker --listen` combined mode and `devintern serve` alias remain temporarily available with deprecation warnings. +`devintern webhook serve` runs the advanced repo-local webhook listener. Keep it separate from the workspace worker so webhook delivery and tracker automation can be operated independently. ```bash # Development diff --git a/docs/code/worker.md b/docs/code/worker.md index 104f323..e136518 100644 --- a/docs/code/worker.md +++ b/docs/code/worker.md @@ -8,7 +8,7 @@ dateModified: 2026-08-26 # Worker Daemon -`devintern worker` runs devintern as a single long-running daemon on your own machine. It replaces the cron plus standalone webhook server setup: one process acquires events (reviews on the agent's PRs, ready tasks from your tracker) and executes them locally. +`devintern worker` runs devintern as a single long-running workspace daemon on your own machine. It acquires events for every configured repository and executes them locally. Your code, credentials, and agent execution never leave your machine. @@ -36,16 +36,15 @@ devintern worker --query "status=todo" devintern webhook serve ``` -`devintern worker --listen` preserves the old combined single-repo process for compatibility, but is deprecated. Run the workspace worker and `devintern webhook serve` as separate processes instead. `devintern serve` remains a deprecated alias for `devintern webhook serve`. - ## Recurring automations -For a single repository, put recurring work in `.devintern-code/automations.toml`: +Put recurring work in `workspace.toml`. Set `repo` when the workspace has multiple repositories; it is optional for a one-repo workspace: ```toml [[automations]] id = "dependency-health" enabled = true +repo = "web-app" interval = "6h" prompt = """Pick one outdated dependency and upgrade it within the same major version. Run the test suite; if anything breaks, revert the upgrade instead of fixing forward.""" @@ -53,6 +52,7 @@ Run the test suite; if anything breaks, revert the upgrade instead of fixing for [[automations]] id = "flaky-test-triage" enabled = true +repo = "web-app" cron = "0 9 * * 1" prompt = """Re-run the test suite twice and look for flaky tests. For each flaky test, add a short comment explaining the suspected race condition. @@ -61,7 +61,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. An automation file is itself a valid event source, so `devintern worker` stays running without `--query` or `--listen` when at least one automation entry is configured (disabled entries are validated but not scheduled). +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). ### What an automation is @@ -69,14 +69,14 @@ Automations are independent of your task tracker: **the prompt is the task**. Ea Concretely, each occurrence: -1. Writes `.devintern-code/automations//.md` (resolved like every other devintern state: the nearest `.devintern-code` walking up from the working directory). +1. Writes `~/.devintern/automations//.md` (or the equivalent under `DEVINTERN_WORKSPACE_DIR`). 2. Spawns the normal CLI on that file as a subprocess, so the run gets its own branch, commits, and — by default — a pull request. 3. Records the attempt with the `scheduled` origin and the automation id, so you can filter scheduled runs in the [dashboard](./dashboard.md). Because the occurrence is just a markdown task, you can reproduce or rerun any occurrence by hand: ```bash -devintern .devintern-code/automations/dependency-health/2026-08-24T09-00-00-000Z.md +devintern ~/.devintern/automations/dependency-health/2026-08-24T09-00-00-000Z.md ``` ### Writing good prompts @@ -106,7 +106,7 @@ On shutdown the scheduler stops its timer, terminates active automation subproce | `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). | -| Task files pile up under `.devintern-code/automations/` | They are small and safe to delete — they are only run inputs; the durable record is the run history in `queue.db`. | +| Task files pile up under `~/.devintern/automations/` | They are small and safe to delete — they are only run inputs; the durable record is the run history in `queue.db`. | ## Polling mode @@ -155,9 +155,6 @@ The worker log is the diagnostic. Look for `[poll:]` (for Jira, `[poll: | Option | Description | | ------------------- | ------------------------------------------------------------------- | | `--query ` | Poll the tracker for ready tasks matching this query | -| `--listen` | Deprecated combined repo-local webhook listener | -| `--port ` | Deprecated listener port used only with `--listen` | -| `--host ` | Deprecated listener host used only with `--listen` | | `--interval ` | Polling interval in seconds (default: 60 or `WORKER_POLL_INTERVAL`) | | `--ui` | Serve the local [observability dashboard](./dashboard.md) (default) | | `--no-ui` | Disable the dashboard for this worker process | @@ -171,9 +168,9 @@ Unattended automation is exactly where sandboxing the agent matters most: set `A In polling mode the worker also watches the pull requests it created (no webhook needed). When a human requests changes or leaves new inline review comments on one of the agent's own PRs, the worker addresses the feedback automatically; no mention is required on its own PRs. Closed and merged PRs leave the watch list on their own. -The watch list is scoped to the project the worker runs in: single-repo mode watches only PRs on that checkout's GitHub repo, and workspace mode only repos listed in `workspace.toml`. Registry entries for any other repo — typically left behind when a repository is renamed or transferred, or by an older checkout sharing the same `.devintern-code/` state — are unwatched automatically at startup instead of being polled (and failing auth) forever. +The watch list is scoped to repos listed in `workspace.toml`. Registry entries for any other repo — typically left behind when a repository is renamed or transferred — are unwatched automatically at startup instead of being polled (and failing auth) forever. -The regular polling requests use ETags, and GitHub does not count `304 Not Modified` responses against the API rate limit. The worker makes unconditional PR requests only once to hydrate state after startup and immediately before an eligible base-sync attempt. Comparison results are reused for each immutable base/head SHA pair. The deprecated combined `--listen` mode disables this poller so feedback is never handled twice. +The regular polling requests use ETags, and GitHub does not count `304 Not Modified` responses against the API rate limit. The worker makes unconditional PR requests only once to hydrate state after startup and immediately before an eligible base-sync attempt. Comparison results are reused for each immutable base/head SHA pair. ### Merge conflicts on the agent's PRs diff --git a/docs/code/workspaces.md b/docs/code/workspaces.md index 5beb396..9453eeb 100644 --- a/docs/code/workspaces.md +++ b/docs/code/workspaces.md @@ -116,7 +116,6 @@ For GitHub remotes the worker fills `GITHUB_REPO` automatically from the remote ```bash devintern worker # auto-detects ~/.devintern/workspace.toml devintern worker --workspace /path/to/workspace.toml -devintern worker --no-workspace # force single-repo mode in the current repo ``` The fleet query comes from `[defaults].task_query`, or `--query` / `WORKER_TASK_QUERY` to override. A workspace with automations can omit the query and run as an automation-only worker. 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. diff --git a/packages/code/CHANGELOG.md b/packages/code/CHANGELOG.md index 81ec052..fad47ba 100644 --- a/packages/code/CHANGELOG.md +++ b/packages/code/CHANGELOG.md @@ -6,7 +6,7 @@ - **`devintern worker init` is the complete unattended setup**: the wizard reuses tracker config from `devintern init` (or runs that subset), imports the current repo into `~/.devintern/workspace.toml` as a 1-repo workspace, dry-runs the ready-tasks query into `[defaults].task_query`, checks any automation license, offers zero-port relay pairing, and generates a user-level systemd unit or macOS launchd agent. It no longer asks about `--listen` or writes worker env vars - **Worker dashboard is on by default**: `devintern worker` serves localhost:4400 unless `--no-ui` is passed; dashboard startup failures no longer stop task processing -- **Direct webhooks have a dedicated command**: `devintern webhook serve` is the canonical advanced repo-local listener. `devintern worker --listen` keeps its legacy combined behavior for compatibility but is deprecated, and `devintern serve` is now a deprecated alias for the dedicated command +- **Direct webhooks have a dedicated command**: `devintern webhook serve` is the advanced repo-local listener. The legacy `devintern worker --listen`, `devintern serve`, `--no-workspace`, and cwd worker modes are removed; `devintern worker` now always requires a workspace ### Fixed diff --git a/packages/code/src/index.ts b/packages/code/src/index.ts index 6e30707..1d5602a 100755 --- a/packages/code/src/index.ts +++ b/packages/code/src/index.ts @@ -86,7 +86,6 @@ import { Utils } from "./lib/utils"; import { isCommitAlreadyComplete, runAgentHarnessToFixGitHook } from "./lib/git-hook-fixer"; import { runAutoReviewLoop } from "./lib/auto-review-loop"; import { isAutomatedEnvironment } from "./lib/env-detector"; -import { parseEnvInteger } from "./lib/env-integer"; import type { BaseProjectConfig, ProjectSettings, TrackerSection } from "./types/settings"; // Version is injected at build time via --define flag, or read from package.json in dev @@ -571,8 +570,7 @@ if (process.argv[2] === "init") { process.exit(0); })(); } else if (process.argv[2] === "worker") { - // Handle worker command - long-running workspace daemon. Deprecated - // --listen keeps the previous combined single-repo behavior temporarily. + // Handle worker command - long-running workspace daemon. (async () => { loadedEnvPath = loadEnvironment(); @@ -619,9 +617,6 @@ if (process.argv[2] === "init") { process.exit(result.ok ? 0 : 1); } - let listen = false; - let port = parseInt(process.env.WEBHOOK_PORT || "3000", 10); - let host = process.env.WEBHOOK_HOST || "0.0.0.0"; let intervalSeconds = parseInt(process.env.WORKER_POLL_INTERVAL || "60", 10); let workerQuery = process.env.WORKER_TASK_QUERY; let verbose = false; @@ -629,25 +624,23 @@ if (process.argv[2] === "init") { let uiPort: number | undefined; let workspacePath: string | undefined; let workspaceFlag = false; - let noWorkspace = false; for (let i = 0; i < args.length; i++) { - if (args[i] === "--listen") { - listen = true; - } else if (args[i] === "--workspace") { + if (args[i] === "--workspace") { workspaceFlag = true; if (args[i + 1] && !args[i + 1].startsWith("-")) { workspacePath = args[i + 1]; i++; } - } else if (args[i] === "--no-workspace") { - noWorkspace = true; - } else if (args[i] === "--port" && args[i + 1]) { - port = parseInt(args[i + 1], 10); - i++; - } else if (args[i] === "--host" && args[i + 1]) { - host = args[i + 1]; - i++; + } else if ( + args[i] === "--listen" || + args[i] === "--no-workspace" || + args[i] === "--port" || + args[i] === "--host" + ) { + console.error(`❌ ${args[i]} has been removed from devintern worker.`); + console.error(" Use the workspace worker or `devintern webhook serve`."); + process.exit(1); } else if (args[i] === "--interval" && args[i + 1]) { intervalSeconds = parseInt(args[i + 1], 10); i++; @@ -688,12 +681,6 @@ if (process.argv[2] === "init") { console.log(" --workspace [path] Fleet mode: serve every repo in the workspace"); console.log(" (~/.devintern/workspace.toml, or the given path)."); console.log(" Auto-enabled when a workspace exists."); - console.log(" --no-workspace Ignore an existing workspace; single-repo mode"); - console.log(" --listen Deprecated: also run the repo-local webhook listener"); - console.log(" --port Webhook listener port (default: 3000 or WEBHOOK_PORT)"); - console.log( - " --host Webhook listener host (default: 0.0.0.0 or WEBHOOK_HOST)", - ); console.log(" --interval Polling interval in seconds (default: 60)"); console.log(" --ui Serve the local dashboard (default)"); console.log(" --no-ui Disable the dashboard for this worker process"); @@ -704,9 +691,6 @@ if (process.argv[2] === "init") { console.log(" -h, --help Display this help message"); console.log(""); console.log("Environment variables:"); - console.log( - " WEBHOOK_SECRET (required with --listen) GitHub webhook signature secret", - ); console.log(" WORKER_TASK_QUERY Task-selection query (same as --query)"); console.log(" WORKER_TASK_ARGS Extra CLI flags per task run (default: --create-pr)"); console.log(" WORKER_POLL_INTERVAL Polling interval in seconds (default: 60)"); @@ -723,42 +707,23 @@ if (process.argv[2] === "init") { const { hasWorkspace, resolveWorkspaceDir, workspaceEnvPath } = await import("./lib/workspace/paths"); - const { resolveWorkerWorkspaceMode } = await import("./lib/worker-mode"); - const selectedWorkerMode = resolveWorkerWorkspaceMode({ - workspaceRequested: workspaceFlag, - noWorkspace, - listen, - workspaceExists: hasWorkspace(), - }); - if (selectedWorkerMode.conflict === "workspace-listen") { - console.error( - "❌ --workspace and --listen cannot be combined.\n" + - " Run `devintern worker --workspace ...` and `devintern webhook serve` as separate processes.", - ); + const workspaceMode = workspaceFlag || hasWorkspace(); + if (!workspaceMode) { + console.error("❌ No workspace configured. Run `devintern worker init` first."); process.exit(1); } - if (listen) { - console.warn( - "⚠️ `devintern worker --listen` is deprecated.\n" + - " Run `devintern worker` and `devintern webhook serve` as separate processes.\n" + - " Continuing in legacy single-repo mode for this release.", - ); - } - const workspaceMode = selectedWorkerMode.workspace; // Workspace credentials must be available before the license gate. This // matters for native services, whose working directory is the workspace // home rather than a source checkout. - if (workspaceMode && !listen) { - const { parseEnvFile } = await import("./lib/workspace/env"); - const selectedWorkspaceDir = workspacePath - ? dirname(resolve(workspacePath)) - : resolveWorkspaceDir(); - for (const [key, value] of Object.entries( - parseEnvFile(workspaceEnvPath(selectedWorkspaceDir)), - )) { - if (process.env[key] === undefined) process.env[key] = value; - } + const { parseEnvFile } = await import("./lib/workspace/env"); + const selectedWorkspaceDir = workspacePath + ? dirname(resolve(workspacePath)) + : resolveWorkspaceDir(); + for (const [key, value] of Object.entries( + parseEnvFile(workspaceEnvPath(selectedWorkspaceDir)), + )) { + if (process.env[key] === undefined) process.env[key] = value; } // License check — the worker is unattended automation, so it always @@ -771,421 +736,16 @@ if (process.argv[2] === "init") { }); requireLicense(licenseResult); - // Workspace (fleet) mode: one daemon serves every repo in the workspace. - // Explicit --workspace wins; otherwise auto-detect ~/.devintern/workspace.toml - // unless --no-workspace. - if (workspaceMode && !listen) { - const { runWorkspaceWorker } = await import("./lib/workspace/workspace-worker"); - await runWorkspaceWorker({ - workspacePath, - query: workerQuery, - intervalSeconds, - verbose, - ui, - uiPort, - }); - return; - } - const { startWorker } = await import("./worker"); - const { WorkerState } = await import("./lib/worker-state"); - const { WebhookQueue, resolveQueueDbPath, LEGACY_DB_PATH } = - await import("./lib/webhook-queue"); - const dbPath = resolveQueueDbPath(); - const acquirers = []; - - const { loadSingleRepoAutomations } = await import("./lib/automation-config"); - const automations = loadSingleRepoAutomations(); - if (automations.length > 0) { - const { AutomationAcquirer } = await import("./lib/automation-acquirer"); - acquirers.push( - new AutomationAcquirer({ - automations, - dbPath, - resolveContext: async () => { - const runLock = new LockManager(process.cwd()); - if (!runLock.acquire().success) return null; - return { - cwd: process.cwd(), - env: { ...process.env }, - release: () => runLock.release(), - }; - }, - }), - ); - } - - if (workerQuery) { - const trackerType = process.env.TASK_TRACKER || "jira"; - const { supportsPolling, trackersSupportingPolling } = - await import("./lib/tracker-capabilities"); - if (!supportsPolling(trackerType)) { - console.error( - `❌ Worker polling is not available for tracker '${trackerType}' yet.\n` + - ` Trackers with polling support: ${trackersSupportingPolling().join(", ")}\n` + - ` You can still use --listen for webhook-driven review handling.`, - ); - process.exit(1); - } - - const { TaskPollingAcquirer, runTaskViaCli } = await import("./lib/task-polling-acquirer"); - const { TaskTrackerManager } = await import("./lib/task-tracker-manager"); - - const tracker = new TaskTrackerManager().getClient(); - - const { createChangeDetector } = await import("./lib/change-detector"); - const detector = createChangeDetector(trackerType, (q) => tracker.searchTasks(q)); - if (!detector) { - console.error( - `❌ Could not initialize the ${trackerType} change detector. ` + - `Check the tracker's required environment variables.`, - ); - process.exit(1); - } - - acquirers.push( - new TaskPollingAcquirer({ - trackerType, - query: workerQuery, - intervalSeconds, - detector, - workerState: new WorkerState(dbPath), - queue: new WebhookQueue({ dbPath, legacyDbPath: LEGACY_DB_PATH }), - searchTasks: (q) => tracker.searchTasks(q), - executeTask: (taskKey) => runTaskViaCli(taskKey), - verbose, - }), - ); - } - - // Tier 1 review polling: watch the agent's own PRs (agent_prs registry) - // and address review feedback without an @mention. Skipped with --listen - // (webhooks already deliver reviews there — polling too would double-run) - // and without GitHub credentials. - if (!listen && (process.env.GITHUB_TOKEN || process.env.GITHUB_APP_ID)) { - const { ReviewPollingAcquirer, runAddressReviewViaCli, runResolveConflictsViaCli } = - await import("./lib/review-polling-acquirer"); - const { GitHubReviewsClient } = await import("./lib/github-reviews"); - const { RunStore } = await import("./lib/run-recorder"); - const gh = new GitHubReviewsClient({ preferAppAuth: true }); - const ownerOf = (repo: string) => repo.split("/")[0] as string; - const nameOf = (repo: string) => repo.split("/")[1] as string; - - // Any run still in_progress predates this process: the previous worker - // was killed or crashed mid-run. Mark those failed so the dashboard and - // stats do not show phantom active runs forever. - const runStore = new RunStore(dbPath); - const reapedRuns = runStore.reapOrphanedRuns(); - if (reapedRuns > 0) { - console.warn( - `⚠️ Marked ${reapedRuns} in-progress run(s) as failed: previous worker exited before they finished`, - ); - } - - // This checkout's GitHub slug scopes review polling to the agent PRs - // of this project; registry rows for any other repo are stale (e.g. - // left behind by a rename/transfer) and get auto-unwatched at start. - const repoSlug = - process.env.GITHUB_REPO || - (await new PRManager() - .detectRepository() - .then((r) => (r.platform === "github" ? r.repository : ""))); - - acquirers.push( - new ReviewPollingAcquirer({ - intervalSeconds, - workerState: new WorkerState(dbPath), - queue: new WebhookQueue({ - dbPath, - legacyDbPath: LEGACY_DB_PATH, - maxRetries: parseEnvInteger("WEBHOOK_MAX_RETRIES", 3, { min: 0 }), - }), - github: { - fetchPr: (repo, n, etag) => - gh.conditionalGet(`/repos/${repo}/pulls/${n}`, ownerOf(repo), nameOf(repo), etag), - fetchReviews: (repo, n, etag) => - gh.conditionalGet( - `/repos/${repo}/pulls/${n}/reviews?per_page=100`, - ownerOf(repo), - nameOf(repo), - etag, - ), - fetchReviewCommentsSince: async (repo, n, sinceIso) => { - const result = await gh.conditionalGet< - Array<{ id: number; user: { login: string; type: string }; created_at: string }> - >( - `/repos/${repo}/pulls/${n}/comments?since=${encodeURIComponent(sinceIso)}&per_page=100`, - ownerOf(repo), - nameOf(repo), - ); - return result.data ?? []; - }, - }, - addressPr: (repo, n) => runAddressReviewViaCli(repo, n), - resolveConflicts: (repo, n, expected) => - runResolveConflictsViaCli(repo, n, { - expectedHeadSha: expected.headSha, - expectedBaseSha: expected.baseSha, - }), - quietPeriodSeconds: parseEnvInteger("WORKER_BASE_SYNC_QUIET_SECONDS", 30, { min: 0 }), - runStore, - allowedRepos: repoSlug ? [repoSlug] : undefined, - verbose, - }), - ); - - // Tier 2 mention sweep: react to @mentions on ANY PR in the repo (two - // since-cursor requests per tick). Permission + mention gates apply in - // the shared review pipeline; fork PRs without maintainer_can_modify - // are skipped with an explanatory comment. - if (repoSlug) { - const { MentionSweepAcquirer } = await import("./lib/mention-sweep-acquirer"); - const { processIssueCommentAsync, DEFAULT_CONFIG } = await import("./webhook-server"); - const [repoOwner, repoName] = repoSlug.split("/") as [string, string]; - const sweepUser = (user: { login: string; type: string }) => ({ - login: user.login, - id: 0, - avatar_url: "", - type: user.type as "User" | "Bot" | "Organization", - }); - - acquirers.push( - new MentionSweepAcquirer({ - repo: repoSlug, - intervalSeconds, - workerState: new WorkerState(dbPath), - queue: new WebhookQueue({ dbPath, legacyDbPath: LEGACY_DB_PATH }), - github: { - fetchIssueCommentsSince: async (sinceIso) => { - const result = await gh.conditionalGet< - Array<{ - id: number; - body: string | null; - user: { login: string; type: string }; - created_at: string; - html_url: string; - issue_url?: string; - }> - >( - `/repos/${repoSlug}/issues/comments?since=${encodeURIComponent(sinceIso)}&per_page=100&sort=created&direction=asc`, - repoOwner, - repoName, - ); - return result.data ?? []; - }, - fetchReviewCommentsSince: async (sinceIso) => { - const result = await gh.conditionalGet< - Array<{ - id: number; - body: string | null; - user: { login: string; type: string }; - created_at: string; - html_url: string; - pull_request_url?: string; - }> - >( - `/repos/${repoSlug}/pulls/comments?since=${encodeURIComponent(sinceIso)}&per_page=100&sort=created&direction=asc`, - repoOwner, - repoName, - ); - return result.data ?? []; - }, - getBotUsername: () => gh.getBotUsername(repoOwner, repoName), - getPr: async (prNumber) => { - const pr = await gh.getPullRequest(repoOwner, repoName, prNumber); - return { - number: pr.number, - state: pr.state, - headRepoFullName: pr.head.repo?.full_name, - maintainerCanModify: pr.maintainer_can_modify, - }; - }, - postComment: (prNumber, body) => - gh.postPullRequestComment(repoOwner, repoName, prNumber, body).then(() => {}), - }, - handleMention: (comment, prNumber) => - processIssueCommentAsync( - { - action: "created", - issue: { - number: prNumber, - title: "", - state: "open", - user: sweepUser(comment.user), - pull_request: { url: "", html_url: comment.html_url }, - }, - comment: { - id: comment.id, - body: comment.body, - user: sweepUser(comment.user), - html_url: comment.html_url, - created_at: comment.created_at, - }, - repository: { - id: 0, - name: repoName, - full_name: repoSlug, - private: false, - owner: { login: repoOwner, id: 0, avatar_url: "", type: "User" }, - html_url: `https://github.com/${repoSlug}`, - default_branch: "main", - }, - sender: sweepUser(comment.user), - }, - DEFAULT_CONFIG, - ), - verbose, - }), - ); - } else if (verbose) { - console.log(" Mention sweep disabled: could not determine the GitHub repo slug."); - } - } - - // Mode 2 relay: long-poll the control plane for reference envelopes when - // this project is connected (`devintern worker connect`) or a relay URL - // is set explicitly. Mode 1 polling keeps running as the fallback sweep; - // dedupe and live-state re-derivation bound double-triggers. - const { loadRelayState } = await import("./lib/relay-connect"); - const relayState = loadRelayState(); - if (relayState || process.env.WORKER_RELAY_URL) { - const relayToken = relayState?.relayToken; - const relayUrl = - process.env.WORKER_RELAY_URL?.replace(/\/+$/, "") || (relayState?.relayUrl ?? ""); - if (!relayToken) { - console.warn( - "⚠️ Relay is configured but no relay token is stored — run `devintern worker connect` while signed in (`devintern login`). Mode 1 polling continues.", - ); - } else if (relayUrl) { - const { RelayAcquirer } = await import("./lib/relay-acquirer"); - const { runAddressReviewViaCli } = await import("./lib/review-polling-acquirer"); - const { runTaskViaCli } = await import("./lib/task-polling-acquirer"); - const { processIssueCommentAsync, DEFAULT_CONFIG } = await import("./webhook-server"); - const { mentionsBot } = await import("./lib/mention-sweep-acquirer"); - const relayWorkerState = new WorkerState(dbPath); - - const hasGitHubCreds = Boolean(process.env.GITHUB_TOKEN || process.env.GITHUB_APP_ID); - const { GitHubReviewsClient } = await import("./lib/github-reviews"); - const relayGh = hasGitHubCreds ? new GitHubReviewsClient({ preferAppAuth: true }) : null; - - // Task envelopes re-evaluate the user's query before running - // (detect-then-evaluate, same as the polling acquirer). - const relayTracker = workerQuery - ? await (async () => { - const { TaskTrackerManager } = await import("./lib/task-tracker-manager"); - return new TaskTrackerManager().getClient(); - })() - : null; - - const relayUser = (user: { login: string; type: string }) => ({ - login: user.login, - id: 0, - avatar_url: "", - type: user.type as "User" | "Bot" | "Organization", - }); - - acquirers.push( - new RelayAcquirer({ - relayUrl, - relayToken, - workerState: relayWorkerState, - queue: new WebhookQueue({ dbPath, legacyDbPath: LEGACY_DB_PATH }), - isAgentPr: (repo, prNumber) => - relayWorkerState.listOpenAgentPrs(repo).some((pr) => pr.prNumber === prNumber), - handlers: { - addressPr: async (repo, prNumber) => { - await runAddressReviewViaCli(repo, prNumber); - }, - handlePrComment: async (repo, prNumber, commentId) => { - if (!relayGh) { - return; - } - const [repoOwner, repoName] = repo.split("/") as [string, string]; - // Fetch the referenced comment (envelopes never carry text), - // then pre-filter for the bot mention before entering the - // pipeline; the permission gate applies inside. - const { data: comment } = await relayGh.conditionalGet<{ - id: number; - body: string | null; - user: { login: string; type: string }; - created_at: string; - html_url: string; - }>(`/repos/${repo}/issues/comments/${commentId}`, repoOwner, repoName); - if (!comment) { - return; - } - const botName = await relayGh.getBotUsername(repoOwner, repoName); - if (!botName || !mentionsBot(comment.body, botName)) { - return; - } - console.log(`📌 [relay] @mention on ${repo}#${prNumber}`); - await processIssueCommentAsync( - { - action: "created", - issue: { - number: prNumber, - title: "", - state: "open", - user: relayUser(comment.user), - pull_request: { url: "", html_url: comment.html_url }, - }, - comment: { - id: comment.id, - body: comment.body, - user: relayUser(comment.user), - html_url: comment.html_url, - created_at: comment.created_at, - }, - repository: { - id: 0, - name: repoName, - full_name: repo, - private: false, - owner: { login: repoOwner, id: 0, avatar_url: "", type: "User" }, - html_url: `https://github.com/${repo}`, - default_branch: "main", - }, - sender: relayUser(comment.user), - }, - DEFAULT_CONFIG, - ); - }, - evaluateTask: async (taskKey) => { - if (!relayTracker || !workerQuery) { - if (verbose) { - console.log( - ` [relay] task ${taskKey} changed but no --query is configured; skipping.`, - ); - } - return; - } - const { tasks } = await relayTracker.searchTasks(workerQuery); - if (!tasks.some((task) => task.key === taskKey)) { - return; - } - console.log(`📌 [relay] task ${taskKey} is ready`); - await runTaskViaCli(taskKey); - }, - }, - verbose, - }), - ); - } - } - - // Local observability dashboard alongside the daemon (same server module - // as the standalone `devintern dashboard` command; reads the DB read-only). - if (ui) { - try { - const { startDashboardServer } = await import("./dashboard-server"); - startDashboardServer({ port: uiPort }); - } catch (error) { - console.warn( - `⚠️ Dashboard could not start (${(error as Error).message}); the worker will continue.`, - ); - } - } - - await startWorker({ listen, port, host, intervalSeconds, verbose }, acquirers); + const { runWorkspaceWorker } = await import("./lib/workspace/workspace-worker"); + await runWorkspaceWorker({ + workspacePath, + query: workerQuery, + intervalSeconds, + verbose, + ui, + uiPort, + }); + return; })(); } else if (process.argv[2] === "workspace") { // Workspace management: scaffold or grow the multi-repo fleet config. @@ -1281,12 +841,6 @@ if (process.argv[2] === "init") { } await runWebhookServeCommand(process.argv.slice(4)); })(); -} else if (process.argv[2] === "serve") { - // Deprecated alias for `devintern webhook serve`. - (async () => { - console.warn("⚠️ `devintern serve` is deprecated; use `devintern webhook serve` instead."); - await runWebhookServeCommand(process.argv.slice(3)); - })(); } else if (process.argv[2] === "address-review") { // Handle address-review command - manually address PR review feedback (async () => { @@ -1628,7 +1182,6 @@ Subcommands: 'worker init' writes a workspace and ready-tasks query dashboard Serve the local observability dashboard (run history and stats) webhook serve Start the advanced repo-local direct-webhook server - serve Deprecated alias for 'webhook serve' address-review Address review feedback on an existing pull request resolve-conflicts Merge a PR's base branch into it, resolving conflicts login [method] Sign in (github | google | x | email; prompts if omitted) @@ -1649,7 +1202,6 @@ const isSubcommand = [ "dashboard", "workspace", "webhook", - "serve", "address-review", "resolve-conflicts", "login", diff --git a/packages/code/src/lib/review-polling-acquirer.ts b/packages/code/src/lib/review-polling-acquirer.ts index ff0c18a..c684607 100644 --- a/packages/code/src/lib/review-polling-acquirer.ts +++ b/packages/code/src/lib/review-polling-acquirer.ts @@ -192,8 +192,8 @@ async function serializePrRun( * @param repo - `owner/repo` slug * @param prNumber - Pull request number * @param opts - Working directory and environment for the subprocess; - * workspace mode runs from the repo's base worktree with - * per-repo env, single-repo mode inherits both + * the workspace worker runs from the repo's base worktree with + * per-repo env; direct callers inherit both */ export function runAddressReviewViaCli( repo: string, diff --git a/packages/code/src/lib/task-polling-acquirer.ts b/packages/code/src/lib/task-polling-acquirer.ts index 383de3f..40d1bd3 100644 --- a/packages/code/src/lib/task-polling-acquirer.ts +++ b/packages/code/src/lib/task-polling-acquirer.ts @@ -72,8 +72,8 @@ export function workerTaskArgs(): string[] { * @param taskKey - Task key to process * @param extraArgs - CLI flags (default from {@link workerTaskArgs}) * @param opts - Working directory and environment for the subprocess; - * workspace mode routes each task to its repo's worktree with - * per-repo env, single-repo mode inherits both + * the workspace worker routes each task to its repo's worktree + * with per-repo env; direct callers inherit both * @returns true when the CLI exited 0 */ export function runTaskViaCli( diff --git a/packages/code/src/lib/worker-init.ts b/packages/code/src/lib/worker-init.ts index 9da09b4..221b8a3 100644 --- a/packages/code/src/lib/worker-init.ts +++ b/packages/code/src/lib/worker-init.ts @@ -3,7 +3,7 @@ * * Writes a workspace (first import is N=1) instead of `WORKER_TASK_QUERY` in * `.env`, dry-runs the ready-tasks query, and checks any automation license. - * Polling is always on; `--listen` is not part of this wizard. + * Polling is always on; direct webhooks run as a separate advanced service. * * Prompt-loop mechanics come from `@devintern/task-trackers` (shared with * `devintern init`); everything effectful is injectable for tests. diff --git a/packages/code/src/lib/worker-mode.ts b/packages/code/src/lib/worker-mode.ts deleted file mode 100644 index e93a8f1..0000000 --- a/packages/code/src/lib/worker-mode.ts +++ /dev/null @@ -1,26 +0,0 @@ -export interface WorkerWorkspaceModeOptions { - workspaceRequested: boolean; - noWorkspace: boolean; - listen: boolean; - workspaceExists: boolean; -} - -export interface WorkerWorkspaceMode { - workspace: boolean; - conflict?: "workspace-listen"; -} - -/** Resolve legacy single-repo versus workspace mode before worker startup. */ -export function resolveWorkerWorkspaceMode( - options: WorkerWorkspaceModeOptions, -): WorkerWorkspaceMode { - if (options.workspaceRequested && options.listen) { - return { workspace: false, conflict: "workspace-listen" }; - } - - return { - workspace: - options.workspaceRequested || - (!options.noWorkspace && !options.listen && options.workspaceExists), - }; -} diff --git a/packages/code/src/lib/workspace/fleet-events.ts b/packages/code/src/lib/workspace/fleet-events.ts index bc34cb4..f5a29fb 100644 --- a/packages/code/src/lib/workspace/fleet-events.ts +++ b/packages/code/src/lib/workspace/fleet-events.ts @@ -2,11 +2,10 @@ * Fleet event handling: PR reviews, @mentions, and relay envelopes across * every repo in the workspace. * - * The single-repo worker handles mentions in-process (the pipeline operates - * on the current checkout). Fleet mode has no ambient checkout, so every - * event run is a CLI subprocess in the repo's persistent base worktree with - * that repo's composed environment. The permission gate for mention-driven - * runs (write/maintain/admin, fails closed) therefore lives HERE, before the + * The workspace worker has no ambient checkout, so every event run is a CLI + * subprocess in the repo's persistent base worktree with that repo's composed + * environment. The permission gate for mention-driven runs + * (write/maintain/admin, fails closed) therefore lives HERE, before the * subprocess: `devintern address-review` is a manual command and performs no * gating of its own. */ diff --git a/packages/code/src/lib/workspace/workspace-worker.ts b/packages/code/src/lib/workspace/workspace-worker.ts index 7ea907b..c5d3bb5 100644 --- a/packages/code/src/lib/workspace/workspace-worker.ts +++ b/packages/code/src/lib/workspace/workspace-worker.ts @@ -409,9 +409,6 @@ export async function runWorkspaceWorker(options: RunWorkspaceWorkerOptions): Pr const { startWorker } = await import("../../worker"); await startWorker( { - listen: false, - intervalSeconds: options.intervalSeconds, - verbose: options.verbose, lock: createWorkspaceLock(workspaceDir), label: workspaceDir, }, diff --git a/packages/code/src/worker.ts b/packages/code/src/worker.ts index a4e07f9..32634ac 100644 --- a/packages/code/src/worker.ts +++ b/packages/code/src/worker.ts @@ -1,31 +1,18 @@ /** * devintern worker * - * Single long-running daemon on the customer's machine that replaces the - * cron + standalone webhook server setup. Event acquisition modes: + * Single long-running workspace daemon on the customer's machine. Event + * acquirers feed work into the shared queue without exposing a public port. * * - Mode 1 (polling): per-tracker change detectors feed the queue. No public * endpoint, no DevIntern infrastructure. (Acquirers register here as they * land; the detect-then-evaluate loop ships separately.) - * - Mode 3 compatibility (direct webhooks): deprecated `--listen` runs the - * repo-local webhook server inside the daemon. New deployments run - * `devintern webhook serve` as a separate process. - * * Code, credentials, and agent execution never leave this machine. */ -import type { Server } from "http"; - import { LockManager } from "./lib/lock-manager"; export interface WorkerOptions { - /** Deprecated Mode 3 compatibility: also run the GitHub webhook HTTP server. */ - listen: boolean; - port?: number; - host?: string; - /** Mode 1 polling interval in seconds (default 60). */ - intervalSeconds?: number; - verbose?: boolean; /** Single-instance lock override (workspace mode locks the workspace home * instead of the current directory). */ lock?: LockManager; @@ -43,14 +30,11 @@ export interface Acquirer { stop(): Promise | void; } -/** Default Mode 1 polling interval (seconds). */ -export const DEFAULT_POLL_INTERVAL_SECONDS = 60; - /** - * Start the worker daemon: single-instance lock, optional webhook listener, - * registered polling acquirers, and graceful shutdown on SIGINT/SIGTERM. + * Start the worker daemon: single-instance lock, registered event acquirers, + * and graceful shutdown on SIGINT/SIGTERM. * - * @param options - Daemon options (listen mode, port/host, poll interval) + * @param options - Daemon options such as workspace lock and display label * @param acquirers - Polling acquirers to run (Mode 1); empty until detectors register */ export async function startWorker( @@ -58,8 +42,7 @@ export async function startWorker( acquirers: Acquirer[] = [], ): Promise { console.log("👷 Starting devintern worker"); - console.log(` Project: ${options.label ?? process.cwd()}`); - console.log(` Webhook listener (Mode 3): ${options.listen ? "enabled" : "disabled"}`); + console.log(` Workspace: ${options.label ?? process.cwd()}`); console.log( ` Polling sources (Mode 1): ${ acquirers.length > 0 ? acquirers.map((a) => a.name).join(", ") : "none configured" @@ -76,7 +59,7 @@ export async function startWorker( process.exit(1); } - if (!options.listen && acquirers.length === 0) { + if (acquirers.length === 0) { console.error("❌ No event sources enabled."); console.error( " Poll your tracker with: devintern worker --query ''", @@ -86,12 +69,6 @@ export async function startWorker( process.exit(1); } - let server: Server | null = null; - if (options.listen) { - const { startWebhookServer } = await import("./webhook-server"); - server = await startWebhookServer({ port: options.port, host: options.host }); - } - for (const acquirer of acquirers) { await acquirer.start(); } @@ -112,11 +89,6 @@ export async function startWorker( } } - if (server) { - await new Promise((resolve) => server!.close(() => resolve())); - console.log(" Webhook listener stopped"); - } - // In-flight queue events stay marked in SQLite and are recovered on the // next start; shutdown never loses accepted work. lock.release(); diff --git a/packages/code/tests/cli.test.ts b/packages/code/tests/cli.test.ts index a5add13..4c7590e 100644 --- a/packages/code/tests/cli.test.ts +++ b/packages/code/tests/cli.test.ts @@ -99,6 +99,7 @@ describe.concurrent("CLI Argument Handling", () => { expect(result.stdout).toContain("devintern PROJ-123 PROJ-456 PROJ-789 --create-pr"); expect(result.stdout).toContain("devintern ENG-42 ENG-43 ENG-44 --create-pr"); expect(result.stdout).toContain("webhook serve"); + expect(result.stdout).not.toContain("Deprecated alias for 'webhook serve'"); expect(result.exitCode).toBe(0); }); @@ -118,19 +119,21 @@ describe.concurrent("CLI Argument Handling", () => { expect(result.exitCode).toBe(0); }); - test("should keep serve as a deprecated webhook serve alias", async () => { - const result = await runCLI(["serve", "--help"]); - expect(result.stdout).toContain("Usage: devintern webhook serve [options]"); - expect(result.stderr).toContain("`devintern serve` is deprecated"); - expect(result.stderr).toContain("devintern webhook serve"); + test("should omit removed single-repo flags from worker help", async () => { + const result = await runCLI(["worker", "--help"]); + expect(result.stdout).not.toContain("--listen"); + expect(result.stdout).not.toContain("--no-workspace"); + expect(result.stdout).not.toContain("--host"); + expect(result.stdout).not.toContain("--port"); expect(result.exitCode).toBe(0); }); - test("should mark worker --listen as deprecated in help", async () => { - const result = await runCLI(["worker", "--help"]); - expect(result.stdout).toContain("--listen"); - expect(result.stdout).toContain("Deprecated"); - expect(result.exitCode).toBe(0); + test("should reject removed single-repo worker flags", async () => { + for (const flag of ["--listen", "--no-workspace", "--port", "--host"]) { + const result = await runCLI(["worker", flag]); + expect(result.stderr).toContain(`${flag} has been removed from devintern worker`); + expect(result.exitCode).toBe(1); + } }); test("should show version with --version", async () => { @@ -302,6 +305,24 @@ describe.concurrent("CLI Argument Handling", () => { expect(result.stdout).toContain("TEST-123"); }); + test("worker requires a workspace", async () => { + const emptyWorkspaceDir = join( + tmpdir(), + `cli-no-workspace-${Date.now()}-${Math.random().toString(36).substring(7)}`, + ); + mkdirSync(emptyWorkspaceDir, { recursive: true }); + + try { + const result = await runCLI(["worker"], { + env: { DEVINTERN_WORKSPACE_DIR: emptyWorkspaceDir }, + }); + expect(result.stderr).toContain("No workspace configured"); + expect(result.exitCode).toBe(1); + } finally { + rmSync(emptyWorkspaceDir, { recursive: true, force: true }); + } + }); + test("worker --workspace starts without a team-tier license", async () => { // Empty workspace: getting past the license gate lands on the // "No repos configured" startup error instead of a tier upgrade wall. diff --git a/packages/code/tests/worker-mode.test.ts b/packages/code/tests/worker-mode.test.ts deleted file mode 100644 index 052b2c4..0000000 --- a/packages/code/tests/worker-mode.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { resolveWorkerWorkspaceMode } from "../src/lib/worker-mode"; - -describe("resolveWorkerWorkspaceMode", () => { - test("uses an explicitly requested workspace", () => { - expect( - resolveWorkerWorkspaceMode({ - workspaceRequested: true, - noWorkspace: false, - listen: false, - workspaceExists: false, - }), - ).toEqual({ workspace: true }); - }); - - test("auto-detects an existing workspace", () => { - expect( - resolveWorkerWorkspaceMode({ - workspaceRequested: false, - noWorkspace: false, - listen: false, - workspaceExists: true, - }), - ).toEqual({ workspace: true }); - }); - - test("keeps deprecated listen mode repo-local when a workspace is auto-detected", () => { - expect( - resolveWorkerWorkspaceMode({ - workspaceRequested: false, - noWorkspace: false, - listen: true, - workspaceExists: true, - }), - ).toEqual({ workspace: false }); - }); - - test("rejects an explicit workspace with deprecated listen mode", () => { - expect( - resolveWorkerWorkspaceMode({ - workspaceRequested: true, - noWorkspace: false, - listen: true, - workspaceExists: true, - }), - ).toEqual({ workspace: false, conflict: "workspace-listen" }); - }); -});