diff --git a/server/lib/README.md b/server/lib/README.md index 1ea10ade91..d39e6e5609 100644 --- a/server/lib/README.md +++ b/server/lib/README.md @@ -462,7 +462,7 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and | `assetRoutePrefixes.js` | Import-free leaf holding the URL prefixes the server owns: `ASSET_ROUTE_PREFIXES` (every `/data/**` static mount) and `SERVER_OWNED_PREFIXES` (what must never reach the SPA fallback, each with the exact `spaPaths` that ARE client routes). `scripts/dev-proxy-drift.test.js` checks the dev proxy's `^/data/` wildcard against the mounts, pins the route-registration order in `server/index.js` (a router added below the terminators is shadowed), and fails if a client route — from `NAV_COMMANDS` or `App.jsx`'s nested `` tree — is ever added under a server-owned prefix without being declared. | | `asyncMutex.js` | Promise-based async mutex. | | `concurrencyGate.js` | `createConcurrencyGate(limit)` → `run(fn)` — cap on simultaneous async work for ONE module-scoped budget, released FIFO. Sibling to `mapWithConcurrency.js`, which caps in-flight work *within one array map*; a gate is shared state, so several call sites fanning out at the same remote respect one budget instead of each respecting its own while the host sees the sum. `createMutex` (`asyncMutex.js`) is this with `limit` fixed at 1 — prefer it for mutual exclusion. Note the budget is per-MODULE, not per-host: two modules calling one host each get their own gate. Used by `huggingFaceCatalog.js` (4) and `ollamaRegistryCatalog.js` (16), whose cold catalog-enrichment bursts otherwise arrive at a free public API as a thundering herd — which the Hub answers with an HTTP/2 GOAWAY that surfaces as a bare `fetch failed`. | -| `dispatchLabels.js` | slashdo dispatch-hint contract: `model:light/medium/heavy` + `effort:low/medium/high/xhigh/max` vocabulary, prescribed forge colors, validation (`normalizeDispatchModel` / `normalizeDispatchEffort`), GitHub/GitLab vs Jira label formatting, optional contributor labels (`good first issue` / `help wanted`, never implied by `model:light`, and released at claim time by `formatContributorLabelReleaseCommands` — one best-effort command per label, since a forge fails the whole edit when a named label is absent), the open-ended planner-attribution axis (`planner:`, `normalizePlannerId` / `resolvePlannerId` / `formatPlannerLabelGuidance` — records WHICH model wrote the plan, prefix-matched by `dispatchLabelSpec` so it lazily creates like the fixed labels; a filing agent takes the value from its prompt, never from self-identification), the `decomposed` epic workflow marker (`EPIC_DECOMPOSED_LABEL`, shared with perpetualWork.js#isActionableIssue and the claim prompts), lazy-create command text, the optional `--label` slots a rendered `issue create` example offers (`OPTIONAL_ISSUE_LABEL_FLAG_SLOTS` / `formatOptionalIssueLabelFlags` — one list so a new axis reaches every prompt template's copy-pasteable command, not just its prose), and shared dispatch plus issue-quality guidance (`ISSUE_QUALITY_GUIDANCE`, `DISPATCH_HINT_GUIDANCE`, `JIRA_DISPATCH_HINT_GUIDANCE`). Omit an unjustified axis; never invent `medium`; reject future-only/speculative work while keeping useful current refactors claimable. Consumed by work-tracker instructions, quota-burn audits, Layered Intelligence filing, and claim follow-up prompts. | +| `dispatchLabels.js` | slashdo dispatch-hint contract: `model:light/medium/heavy` + `effort:low/medium/high/xhigh/max` vocabulary, prescribed forge colors, validation (`normalizeDispatchModel` / `normalizeDispatchEffort`), GitHub/GitLab vs Jira label formatting, optional contributor labels (`good first issue` / `help wanted`, never implied by `model:light`, and released at claim time by `formatContributorLabelReleaseCommands` — one best-effort command per label, since a forge fails the whole edit when a named label is absent), the open-ended planner-attribution axis (`planner:`, `normalizePlannerId` / `resolvePlannerId` / `formatPlannerLabelGuidance` — records WHICH model wrote the plan, prefix-matched by `dispatchLabelSpec` so it lazily creates like the fixed labels; a filing agent takes the value from its prompt, never from self-identification), the workflow-state markers (`EPIC_LABEL`/`EPIC_DECOMPOSED_LABEL` and `IN_PROGRESS_LABEL` — state, not hints: shared with perpetualWork.js#isActionableIssue, issueReconcile.js's zombie scan, issueWatcher.js's volunteer assignment, and the claim prompts), lazy-create command text, the optional `--label` slots a rendered `issue create` example offers (`OPTIONAL_ISSUE_LABEL_FLAG_SLOTS` / `formatOptionalIssueLabelFlags` — one list so a new axis reaches every prompt template's copy-pasteable command, not just its prose), and shared dispatch plus issue-quality guidance (`ISSUE_QUALITY_GUIDANCE`, `DISPATCH_HINT_GUIDANCE`, `JIRA_DISPATCH_HINT_GUIDANCE`). Omit an unjustified axis; never invent `medium`; reject future-only/speculative work while keeping useful current refactors claimable. Consumed by work-tracker instructions, quota-burn audits, Layered Intelligence filing, and claim follow-up prompts. | | `domainAutonomy.js` | Per-domain autonomy guardrails (pure). `AUTONOMY_DOMAINS`/`DOMAIN_IDS`/`DOMAIN_MODES` (`off`/`dry-run`/`execute`), `getDomainMode(config, id)`, and `normalizeDomainAutonomy(raw)` to coerce a hand-edited/partial map. Default per domain is `execute` (reproduces pre-#711 behavior, so no migration needed). Also `CREATIVE_DOMAIN`/`getCreativeAutonomyMode(config)` (#2183) — the Creative Director orchestrator domain, kept out of `DOMAIN_IDS` and defaulting to mirror the `cos` mode. | | `domainBudgets.js` | Per-domain daily autonomy budgets (pure). `BUDGET_LIMIT_FIELDS` (`maxActionsPerDay`/`maxMinutesPerDay`), `getDomainBudget(config, id)`, `normalizeDomainBudgets(raw)`, `hasBudget(budget)`, and `evaluateBudget(budget, usage)` → `{ withinBudget, exceeded }`. A `null`/non-positive cap means unlimited (default per domain, so no migration needed). Token/$ caps are intentionally absent — CLI subscription providers expose no per-run metering. Usage ledger + gate wiring live in `services/domainUsage.js`. | | `eidoverseWorldDesign.js` | Immutable Eidoverse World Design V1/V2 registry, legacy override migration, semantic district contract, 48-signal ceiling, and deterministic library-only asset-recipe resolution/locking for the PortOS Luminous Systems Garden. | diff --git a/server/lib/dispatchLabels.js b/server/lib/dispatchLabels.js index 192cf867cd..9ef62aa297 100644 --- a/server/lib/dispatchLabels.js +++ b/server/lib/dispatchLabels.js @@ -12,6 +12,11 @@ * Contributor labels (`good first issue`, `help wanted`) are a third, equally * optional axis: apply them when the work is actually onboarding-shaped, not * because `model` happened to be `light`. + * + * The WORKFLOW labels below (`epic`, `decomposed`, `in-progress`) are not hints + * at all — they are state every claim/reconcile flow reads and writes. They live + * here because `dispatchLabelSpec` is what lazily creates any of them on a repo + * that has never defined it. */ import { shellQuote } from './shellQuote.js'; @@ -80,14 +85,26 @@ export const EPIC_DECOMPOSED_LABEL = 'decomposed'; */ export const EPIC_LABEL = 'epic'; +/** + * The claim marker: 'this issue is claimed and being worked'. Written by every + * flow that takes ownership of an issue — a CoS claim, and the issue-watcher's + * deterministic volunteer assignment — and read as a hard skip by the claim + * queue (`perpetualWork.js#NON_ACTIONABLE_ISSUE_LABELS`) and by the zombie scan + * in `issueReconcile.js`. One spelling here so an assigning flow and a + * releasing flow can never drift apart. + */ +export const IN_PROGRESS_LABEL = 'in-progress'; + export const WORKFLOW_LABEL_COLORS = Object.freeze({ [EPIC_DECOMPOSED_LABEL]: 'BFD4F2', [EPIC_LABEL]: 'B60205', + [IN_PROGRESS_LABEL]: 'FFA500', }); export const WORKFLOW_LABEL_DESCRIPTIONS = Object.freeze({ [EPIC_DECOMPOSED_LABEL]: 'Epic already split into per-slice child issues', [EPIC_LABEL]: 'Umbrella/tracking issue — shipped as per-slice children, never as one PR', + [IN_PROGRESS_LABEL]: 'Claimed and being worked', }); /** diff --git a/server/lib/dispatchLabels.test.js b/server/lib/dispatchLabels.test.js index c81522949d..8c66050749 100644 --- a/server/lib/dispatchLabels.test.js +++ b/server/lib/dispatchLabels.test.js @@ -29,6 +29,7 @@ import { dispatchLabelSpec, allDispatchLabelSpecs, formatLabelCreateCommand, + IN_PROGRESS_LABEL, formatRepeatedLabelFlags, CONTRIBUTOR_LABELS, JIRA_CONTRIBUTOR_LABELS, @@ -121,6 +122,17 @@ describe('label specs and CLI formatting', () => { expect(dispatchLabelSpec('model-light')).toBe(null); }); + it('resolves the in-progress workflow marker so it can be lazily created', () => { + // The claim marker is state every claim/reconcile flow reads, so it must be + // creatable on a repo (or fork) that has never defined it. + expect(dispatchLabelSpec(IN_PROGRESS_LABEL)).toEqual({ + name: 'in-progress', + color: 'FFA500', + description: 'Claimed and being worked', + }); + expect(formatLabelCreateCommand(IN_PROGRESS_LABEL)).toContain('gh label create in-progress'); + }); + it('lists all eight specs without dropping an axis', () => { const specs = allDispatchLabelSpecs(); expect(specs).toHaveLength(8); diff --git a/server/services/issueReconcile.js b/server/services/issueReconcile.js index 291db14d62..4d88f746a9 100644 --- a/server/services/issueReconcile.js +++ b/server/services/issueReconcile.js @@ -54,6 +54,7 @@ import { execGit } from '../lib/execGit.js'; import { execGh, ensureForgeReachable } from './github.js'; import { execGlabJson } from './gitlab.js'; import { fetchMyCurrentSprintTickets } from './jira.js'; +import { IN_PROGRESS_LABEL } from '../lib/dispatchLabels.js'; import { resolveAppForgeTarget, resolveRepoForgeTarget } from '../lib/workTracker.js'; import { safeJSONParse, PATHS } from '../lib/fileUtils.js'; @@ -62,10 +63,6 @@ const GH_LIST_LIMIT = 200; // glab paginates via `--per-page`; its practical max page is 100. const GL_PER_PAGE = 100; -// The `in-progress` label = "claimed and being worked". Kept as a constant so -// the scan, the classifier docs, and any future config share one spelling. -export const IN_PROGRESS_LABEL = 'in-progress'; - /** * Extract the issue number a git ref claims, or null. Recognizes both claim * conventions: diff --git a/server/services/issueWatcher.js b/server/services/issueWatcher.js index 29289bb2b9..b44d78c663 100644 --- a/server/services/issueWatcher.js +++ b/server/services/issueWatcher.js @@ -3,9 +3,9 @@ * * The gather pass does the forge work that does not need a model: it reads only * activity newer than the per-app cursor, assigns explicit volunteer comments - * on currently-unassigned issues, finds external PRs without an owner review on - * their current head, and supplies bounded diffs to one reasoning agent. The - * output pass validates that agent's structured decisions against fresh forge + * on currently-unassigned issues and marks them `in-progress`, finds external + * PRs without an owner review on their current head, and supplies bounded diffs + * to one reasoning agent. The output pass validates that agent's structured decisions against fresh forge * state before it replies, posts inline reviews, updates stale branches, or * merges. No model is asked to discover/filter forge records or execute a forge * mutation itself. @@ -18,6 +18,7 @@ import { detectDeterministicModelAbuseSignals, modelAbuseContentFingerprint, } from '../lib/modelAbuseGuard.js'; +import { IN_PROGRESS_LABEL, dispatchLabelSpec } from '../lib/dispatchLabels.js'; import { getOriginInfo } from '../lib/gitRemote.js'; import { MAX_REVIEW_BODY_CHARS, @@ -35,6 +36,8 @@ import { addNotification, NOTIFICATION_TYPES, PRIORITY_LEVELS } from './notifica import { normalizeEligibilityFacts, runModelAbuseScan } from './modelAbuseGuard.js'; import { issuePrerequisiteWaived } from '../lib/modelAbuseGuard.js'; +const IN_PROGRESS_LABEL_SPEC = dispatchLabelSpec(IN_PROGRESS_LABEL); + const GH_TIMEOUT_MS = 60_000; const LIST_LIMIT = 100; const MAX_PULL_REQUESTS_PER_RUN = 3; @@ -245,13 +248,48 @@ async function getRepositoryIdentity(ctx) { return ownerLogin ? { ownerLogin } : null; } +/** + * Take an unassigned issue on a volunteer's behalf: set the assignee and stamp + * `in-progress`. + * + * The assignee is what actually removes the issue from the autonomous claim + * queue — `perpetualWork.js#isActionableIssue` already rejects any issue held + * by someone other than this install's login. The label is for the humans and + * the UI: it is what the Issues tab hides on, and it keeps a volunteer-claimed + * issue reading the same as an agent-claimed one. + * + * ONE combined edit is the fast path. `gh issue edit --add-label` fails the + * WHOLE call when the repo has never defined the label, so the fallback splits + * it — assign alone (the half that must not be lost on a fork), create the + * label, then apply it. `--add-assignee` is idempotent, so the retry is safe + * whether or not the combined attempt got as far as assigning. The label is + * created without `--force` so an install that recolored it keeps its color. + * + * Returns whether the ASSIGNMENT landed, never whether the label did: a missing + * label leaves the issue claimable, which a later pass can repair, while a + * missing assignment means the volunteer's comment still needs an answer and + * must go on to the reasoning agent. + */ async function assignVolunteer(ctx, issueNumber, login) { - return runGh(['issue', 'edit', String(issueNumber), '--repo', ctx.repoSpec, '--add-assignee', login], ctx) + const edit = (...flags) => runGh(['issue', 'edit', String(issueNumber), '--repo', ctx.repoSpec, ...flags], ctx); + const assignFlags = ['--add-assignee', login]; + const labelFlags = ['--add-label', IN_PROGRESS_LABEL]; + if (await edit(...assignFlags, ...labelFlags).then(() => true, () => false)) return true; + + const assigned = await edit(...assignFlags) .then(() => true) .catch((err) => { console.error(`❌ issue-watcher: could not assign issue #${issueNumber} to ${login}: ${err.message}`); return false; }); + if (!assigned) return false; + await runGh(['label', 'create', IN_PROGRESS_LABEL_SPEC.name, '--repo', ctx.repoSpec, + '--color', IN_PROGRESS_LABEL_SPEC.color, '--description', IN_PROGRESS_LABEL_SPEC.description], ctx) + .catch(() => null); + await edit(...labelFlags).catch((err) => { + console.error(`❌ issue-watcher: could not mark issue #${issueNumber} ${IN_PROGRESS_LABEL}: ${err.message}`); + }); + return true; } /** diff --git a/server/services/issueWatcher.test.js b/server/services/issueWatcher.test.js index 60f26cf504..7b000f09d4 100644 --- a/server/services/issueWatcher.test.js +++ b/server/services/issueWatcher.test.js @@ -63,6 +63,7 @@ import { MAX_PENDING_ISSUE_COMMENT_TICKS, } from './issueWatcher.js'; import { MAX_PR_REMEDIATION_ATTEMPTS } from '../lib/prHandbackPolicy.js'; +import { IN_PROGRESS_LABEL, dispatchLabelSpec } from '../lib/dispatchLabels.js'; const APP = { id: 'app-1', name: 'Example App', repoPath: '/repos/example' }; const DIFF = [ @@ -115,10 +116,13 @@ function installDefaultGhMock({ .map((arg) => arg.match(/^repos\/o\/r\/issues\/(\d+)$/)) .find(Boolean); if (args[0] === 'api' && issueDetail) return JSON.stringify(issueDetails[issueDetail[1]] || {}); + // `pr: null` = no open external PRs, so a test can exercise the issue side + // alone without hand-rolling a replacement mock. if (args[0] === 'pr' && args[1] === 'list') { + if (!pr) return '[]'; return JSON.stringify([{ number: pr.number, title: pr.title, author: pr.author, url: pr.url, isDraft: false, headRefOid: pr.headRefOid, updatedAt: '2026-08-30T01:00:00Z' }]); } - if (args[0] === 'api' && args.some((arg) => String(arg).endsWith(`/pulls/${pr.number}/reviews`))) return JSON.stringify(reviews); + if (args[0] === 'api' && pr && args.some((arg) => String(arg).endsWith(`/pulls/${pr.number}/reviews`))) return JSON.stringify(reviews); if (args[0] === 'pr' && args[1] === 'view') return JSON.stringify(pr); if (args[0] === 'api' && args.some((arg) => String(arg).includes('/compare/'))) return JSON.stringify({ behind_by: 2 }); if (args[0] === 'pr' && args[1] === 'diff') return DIFF; @@ -161,6 +165,7 @@ describe('issue-watcher pure contracts', () => { 'I can take this issue', "I'd like to work on it", 'Can you assign this to me?', + "I'd like to work on this — could you assign it to me?", ])('recognizes an explicit volunteer request: %s', (body) => { expect(isIssueClaimRequest(body)).toBe(true); }); @@ -196,6 +201,39 @@ describe('issue-watcher pure contracts', () => { }); describe('buildTaskInput', () => { + /** Recorded `gh` argv lists whose leading arguments match `prefix`. */ + const ghCalls = (...prefix) => execGhMock.mock.calls + .map(([args]) => args) + .filter((args) => prefix.every((value, index) => args[index] === value)); + + /** One unassigned issue carrying one volunteer comment, and no open PRs. */ + function installVolunteerGhMock({ body = 'I can take this issue' } = {}) { + apps.set(APP.id, { ...APP, issueWatcherState: { cursor: '2026-08-29T00:00:00.000Z' } }); + installDefaultGhMock({ + pr: null, + issueRows: [[{ number: 12, title: 'Small task', body: 'Please help', assignees: [] }]], + commentRows: [[{ + id: 99, + body, + created_at: '2026-08-30T00:00:00.000Z', + html_url: 'https://github.com/o/r/issues/12#issuecomment-99', + user: { login: 'alice' }, + }]], + }); + } + + /** + * Layer a failure-injecting handler over the installed mock. Returning + * `undefined` falls through to it, so a test names only the calls it breaks. + */ + function interceptGh(handler) { + const base = execGhMock.getMockImplementation(); + execGhMock.mockImplementation(async (...args) => { + const injected = await handler(args[0]); + return injected === undefined ? base(...args) : injected; + }); + } + it('baselines issue comments but still reviews an existing unreviewed external PR', async () => { installDefaultGhMock(); @@ -220,60 +258,81 @@ describe('buildTaskInput', () => { }); it('assigns an explicit volunteer without spending a cognition run', async () => { - apps.set(APP.id, { ...APP, issueWatcherState: { cursor: '2026-08-29T00:00:00.000Z' } }); - installDefaultGhMock({ - issueRows: [[{ number: 12, title: 'Small task', body: 'Please help', assignees: [] }]], - commentRows: [[{ - id: 99, - body: 'I can take this issue', - created_at: '2026-08-30T00:00:00.000Z', - html_url: 'https://github.com/o/r/issues/12#issuecomment-99', - user: { login: 'alice' }, - }]], - pr: null, - }); - execGhMock.mockImplementation(async (args) => { - if (args[0] === 'api' && args.includes('repos/o/r') && !args.some((arg) => String(arg).includes('/issues'))) { - return JSON.stringify({ owner: { login: 'owner', type: 'User' }, default_branch: 'main' }); - } - if (args[0] === 'api' && args.some((arg) => String(arg).endsWith('/issues'))) { - return JSON.stringify([[{ number: 12, title: 'Small task', body: 'Please help', assignees: [] }]]); + installVolunteerGhMock(); + + const result = await buildTaskInput({ app: apps.get(APP.id) }); + + expect(result).toEqual({ skip: { reason: 'no-cognitive-activity' } }); + expect(ghCalls('issue', 'edit')).toEqual([ + ['issue', 'edit', '12', '--repo', 'github.com/o/r', '--add-assignee', 'alice', '--add-label', 'in-progress'], + ]); + expect(apps.get(APP.id).issueWatcherState.cursor).toMatch(/^2026-/); + }); + + it('recognizes a volunteer request phrased as a question and still claims the issue', async () => { + // Verbatim phrasing of a real volunteer comment — an em dash and a trailing + // request, neither of which the claim regex may be thrown off by. + installVolunteerGhMock({ body: "I'd like to work on this — could you assign it to me?" }); + + const result = await buildTaskInput({ app: apps.get(APP.id) }); + + expect(result).toEqual({ skip: { reason: 'no-cognitive-activity' } }); + expect(ghCalls('issue', 'edit')).toHaveLength(1); + }); + + it('creates the in-progress label and retries when the repo has never defined it', async () => { + installVolunteerGhMock(); + let labelExists = false; + interceptGh((args) => { + if (args[0] === 'label' && args[1] === 'create') { + labelExists = true; + return ''; } - if (args[0] === 'api' && args.some((arg) => String(arg).includes('/comments'))) { - return JSON.stringify([[{ id: 99, body: 'I can take this issue', created_at: '2026-08-30T00:00:00.000Z', user: { login: 'alice' } }]]); + if (args[0] === 'issue' && args[1] === 'edit' && args.includes('--add-label') && !labelExists) { + throw new Error("HTTP 422: 'in-progress' not found"); } - if (args[0] === 'issue' && args[1] === 'edit') return ''; - if (args[0] === 'pr' && args[1] === 'list') return '[]'; - return '{}'; + return undefined; }); const result = await buildTaskInput({ app: apps.get(APP.id) }); expect(result).toEqual({ skip: { reason: 'no-cognitive-activity' } }); - expect(execGhMock).toHaveBeenCalledWith( + // Asserted against the shared spec, not literals: the color/description are + // dispatchLabels.js's contract, covered by its own suite. + expect(ghCalls('label', 'create')[0]).toEqual([ + 'label', 'create', IN_PROGRESS_LABEL, '--repo', 'github.com/o/r', + '--color', dispatchLabelSpec(IN_PROGRESS_LABEL).color, + '--description', dispatchLabelSpec(IN_PROGRESS_LABEL).description, + ]); + // Combined edit (rejected) → assignee alone → label alone. + expect(ghCalls('issue', 'edit')).toEqual([ + ['issue', 'edit', '12', '--repo', 'github.com/o/r', '--add-assignee', 'alice', '--add-label', 'in-progress'], ['issue', 'edit', '12', '--repo', 'github.com/o/r', '--add-assignee', 'alice'], - expect.any(Number), - expect.objectContaining({ cwd: APP.repoPath, env: { GH_TOKEN: 'test-token' } }), - ); - expect(apps.get(APP.id).issueWatcherState.cursor).toMatch(/^2026-/); + ['issue', 'edit', '12', '--repo', 'github.com/o/r', '--add-label', 'in-progress'], + ]); + }); + + it('keeps a volunteer assignment that succeeded when the in-progress label cannot be applied', async () => { + installVolunteerGhMock(); + interceptGh((args) => { + if (args[0] === 'label' && args[1] === 'create') throw new Error('HTTP 403'); + if (args[0] === 'issue' && args[1] === 'edit' && args.includes('--add-label')) throw new Error('HTTP 422'); + return undefined; + }); + + const result = await buildTaskInput({ app: apps.get(APP.id) }); + + // The assignment landed, so the comment is retired rather than handed to + // the reasoning agent — a lost label must not re-spend a cognition run. + expect(result).toEqual({ skip: { reason: 'no-cognitive-activity' } }); + expect(apps.get(APP.id).issueWatcherState.pendingIssueComments).toEqual([]); }); it('continues to cognition when an explicit volunteer cannot be assigned', async () => { - apps.set(APP.id, { ...APP, issueWatcherState: { cursor: '2026-08-29T00:00:00.000Z' } }); - installDefaultGhMock({ pr: null }); - execGhMock.mockImplementation(async (args) => { - if (args[0] === 'api' && args.includes('repos/o/r') && !args.some((arg) => String(arg).includes('/issues'))) { - return JSON.stringify({ owner: { login: 'owner', type: 'User' } }); - } - if (args[0] === 'api' && args.some((arg) => String(arg).endsWith('/issues'))) { - return JSON.stringify([[{ number: 12, title: 'Small task', body: 'Please help', assignees: [] }]]); - } - if (args[0] === 'api' && args.some((arg) => String(arg).includes('/comments'))) { - return JSON.stringify([[{ id: 99, body: 'I can take this issue', created_at: '2026-08-30T00:00:00.000Z', user: { login: 'alice' } }]]); - } + installVolunteerGhMock(); + interceptGh((args) => { if (args[0] === 'issue' && args[1] === 'edit') throw new Error('HTTP 422'); - if (args[0] === 'pr' && args[1] === 'list') return '[]'; - return '{}'; + return undefined; }); const result = await buildTaskInput({ app: apps.get(APP.id) }); diff --git a/server/services/perpetualWork.js b/server/services/perpetualWork.js index 17f7946856..cbb15a5427 100644 --- a/server/services/perpetualWork.js +++ b/server/services/perpetualWork.js @@ -30,10 +30,11 @@ import { parsePlanItems, extractAllIds, findInProgressIds, pickFirstAvailable, e import { readOriginRemoteUrl } from '../lib/gitRemote.js'; import { withGlabJson } from '../lib/glabArgs.js'; import { githubApiHost, hostFromOriginUrl } from '../lib/workTracker.js'; -// The epic marker lives with the forge label vocabulary (name + color + the +// The workflow markers live with the forge label vocabulary (name + color + the // `label create` idiom the prompt bodies interpolate), so the detector and the -// live claim agent cannot drift on what "already decomposed" is spelled. -import { EPIC_DECOMPOSED_LABEL, EPIC_LABEL } from '../lib/dispatchLabels.js'; +// live claim agent cannot drift on how "already decomposed" or "claimed and +// being worked" is spelled. +import { EPIC_DECOMPOSED_LABEL, EPIC_LABEL, IN_PROGRESS_LABEL } from '../lib/dispatchLabels.js'; export { EPIC_DECOMPOSED_LABEL }; @@ -50,7 +51,7 @@ export { EPIC_DECOMPOSED_LABEL }; // `isActionableIssue`'s `excludeLabels` param, so the base skip-list stays the // same across every install. export const NON_ACTIONABLE_ISSUE_LABELS = new Set([ - 'in-progress', 'blocked', 'needs-input', 'future', 'wontfix', 'question', 'discussion' + IN_PROGRESS_LABEL, 'blocked', 'needs-input', 'future', 'wontfix', 'question', 'discussion' ]); const CLI_TIMEOUT_MS = 15000;