(
ws: WorkspaceMeta,
adapter: CliAdapter,
prompt: string,
timeoutMs: number,
// The firing issue's id, when this dispatch came from the ScheduleScanner.
// Manual/external runs (the workspace "run task" route) leave it undefined.
issueId?: string,
)
| 541 | * Throws `HeadlessCapacityError` when too many tasks are already in flight. |
| 542 | */ |
| 543 | const dispatchHeadlessTaskMethod = async ( |
| 544 | ws: WorkspaceMeta, |
| 545 | adapter: CliAdapter, |
| 546 | prompt: string, |
| 547 | timeoutMs: number, |
| 548 | // The firing issue's id, when this dispatch came from the ScheduleScanner. |
| 549 | // Manual/external runs (the workspace "run task" route) leave it undefined. |
| 550 | issueId?: string, |
| 551 | ): Promise<{ taskId: string }> => { |
| 552 | if (!adapter.capabilities.headless || !adapter.composeHeadlessCommand) { |
| 553 | throw new Error(`adapter "${adapter.id}" has no headless mode`); |
| 554 | } |
| 555 | await ensureAgentCredentialReady({ |
| 556 | meta: ws, |
| 557 | agentId: adapter.id, |
| 558 | adapter, |
| 559 | logger: launcherLogger, |
| 560 | }); |
| 561 | if (headlessTasks.runningCount() >= MAX_CONCURRENT_HEADLESS) { |
| 562 | throw new HeadlessCapacityError(MAX_CONCURRENT_HEADLESS); |
| 563 | } |
| 564 | const rec = await headlessTasks.create({ |
| 565 | wsId: ws.id, |
| 566 | agent: adapter.id, |
| 567 | prompt, |
| 568 | startedAt: Date.now(), |
| 569 | ...(issueId ? { issueId } : {}), |
| 570 | }); |
| 571 | // Fire-and-forget: run to natural exit, then fill the record. NOTE: status |
| 572 | // is judged by exit code — pi can exit 0 on an in-band model error, so |
| 573 | // "done" means "process exited cleanly", not "the agent succeeded"; the |
| 574 | // operator confirms via the Inbox / the task's tail. |
| 575 | void runHeadlessTaskMethod(ws, adapter, prompt, timeoutMs, { |
| 576 | taskId: rec.taskId, |
| 577 | onSessionId: (id) => |
| 578 | void headlessTasks |
| 579 | .setAgentSessionId(rec.taskId, id) |
| 580 | .catch((err) => |
| 581 | launcherLogger.warn('headless.session_id_record_failed', { taskId: rec.taskId, err }), |
| 582 | ), |
| 583 | }) |
| 584 | .then(async (r) => { |
| 585 | const status = r.killed ? 'failed' : r.exitCode === 0 ? 'done' : 'failed'; |
| 586 | await headlessTasks.complete(rec.taskId, { |
| 587 | status, |
| 588 | finishedAt: Date.now(), |
| 589 | durationMs: r.durationMs, |
| 590 | exitCode: r.exitCode, |
| 591 | signal: r.signal, |
| 592 | killed: r.killed, |
| 593 | }); |
| 594 | // Scheduled one-shot issues are the only board items whose lifecycle can |
| 595 | // be closed mechanically from a run exit. Repeating schedules keep their |
| 596 | // issue open; failed one-shots stay open so the operator can inspect and |
| 597 | // decide whether to rerun. |
| 598 | try { |
| 599 | const issueCompletion = await completeOneShotIssueAfterRun({ |
| 600 | wsDir: ws.dir, |
nothing calls this directly
no test coverage detected