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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **TUI daemon reliability**: Cockpit refiner, reviewer, and codifier workers now stream bounded activity and errors while running, retain meaningful outcomes and elapsed-time heartbeats, survive navigation between TUI screens, and stop exactly once on request or application shutdown.

## [3.16.0] - 2026-07-09

### Changed
Expand Down
6 changes: 6 additions & 0 deletions docs/guides/advanced-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ Tune the daemon for your workflow:

See the [work refine reference](/reference/commands/work/#jumbo-work-refine) for full option details.

## Run unattended workflows from Cockpit

The TUI Cockpit can run the refiner, reviewer, and codifier as independent background workers. Use **Tab** to focus a daemon panel and **S** to start or stop it. Open the panel configuration with **@** to cycle the agent, poll interval, and retry count before starting.

Running workers continue polling while you navigate to Goals, Memory, or Settings. Returning to Cockpit shows each worker's process status, active goal, attempt, current phase, latest meaningful activity, elapsed time, and recent events. Completion and failure outcomes remain visible across idle polls, and exiting the TUI shuts down every worker through the application lifecycle.

## Run multiple agents in parallel safely

Jumbo supports concurrent workers in separate terminals.
Expand Down
6 changes: 6 additions & 0 deletions src/application/agents/AgentInvocation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
export interface AgentInvocation {
readonly agentId: string;
readonly prompt: string;
readonly onActivity?: (activity: AgentActivity) => void;
}

export interface AgentActivity {
readonly stream: "stdout" | "stderr";
readonly text: string;
}

export interface AgentInvocationResult {
Expand Down
93 changes: 53 additions & 40 deletions src/application/context/goals/codify/CodifierProcessManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
ProcessManagerResult,
ProcessManagerStatus,
} from "../../../daemons/IProcessManager.js";
import { ProcessManagerEventEmitter } from "../../../daemons/ProcessManagerEventEmitter.js";
import { IAgentGateway } from "../../../agents/IAgentGateway.js";
import { ITelemetryClient } from "../../../telemetry/ITelemetryClient.js";
import { IWorkerIdentityReader } from "../../../host/workers/IWorkerIdentityReader.js";
Expand Down Expand Up @@ -68,25 +69,26 @@ export class CodifierProcessManager implements IProcessManager {

async processNext(options: CodifierProcessOptions): Promise<CodifierProcessResult> {
const startedAt = process.hrtime.bigint();
const events = new ProcessManagerEventEmitter(CODIFIER_EVENT_SOURCE, options, startedAt);
events.emit("processing", "polling", "polling for approved goals", { phase: "polling" });
const goals = await this.selectEligibleGoals();

if (goals.length === 0) {
this.emit(options, {
daemon: CODIFIER_EVENT_SOURCE,
status: "idle",
source: CODIFIER_EVENT_SOURCE,
...CODIFIER_EVENT_COPY.noWork,
});
events.emit("idle", CODIFIER_EVENT_COPY.noWork.category, CODIFIER_EVENT_COPY.noWork.message, { phase: "idle" });
this.track("codifier_process_completed", startedAt, { status: "idle", attempts: 0 });
return { status: "idle", attempts: 0 };
}

const goal = goals[0];
events.emit("processing", "selection", "selected goal for codification", {
phase: "selection",
goalId: goal.goalId,
});

try {
await this.codifyGoalController.handle({ goalId: goal.goalId });
} catch (error) {
this.emitFailure(options, goal.goalId, error);
this.emitFailure(events, goal.goalId, error);
this.track("codifier_process_completed", startedAt, {
status: "failed",
attempts: 0,
Expand All @@ -97,31 +99,35 @@ export class CodifierProcessManager implements IProcessManager {
}

for (let attempt = 1; attempt <= options.maxRetries; attempt++) {
this.emit(options, {
daemon: CODIFIER_EVENT_SOURCE,
status: "processing",
source: CODIFIER_EVENT_SOURCE,
const attemptContext = {
goalId: goal.goalId,
attempt,
maxRetries: options.maxRetries,
...CODIFIER_EVENT_COPY.workStarted,
};
events.emit("processing", CODIFIER_EVENT_COPY.workStarted.category, CODIFIER_EVENT_COPY.workStarted.message, {
phase: "working",
...attemptContext,
});

const result = await this.agentGateway.invoke({
let sawAgentActivity = false;
const invocation = {
agentId: options.agentId,
prompt: this.buildPrompt(goal.goalId),
});
onActivity: (activity: { readonly stream: "stdout" | "stderr"; readonly text: string }) => {
sawAgentActivity = true;
events.emitAgentActivity(activity.stream, activity.text, attemptContext);
},
};
const result = await this.agentGateway.invoke(invocation);
if (!sawAgentActivity) {
this.emitReturnedAgentOutput(events, result, attemptContext);
}

if (await this.isGoalDone(goal.goalId)) {
this.emit(options, {
daemon: CODIFIER_EVENT_SOURCE,
status: "completed",
source: CODIFIER_EVENT_SOURCE,
goalId: goal.goalId,
attempt,
maxRetries: options.maxRetries,
events.emit("completed", CODIFIER_EVENT_COPY.completed.category, CODIFIER_EVENT_COPY.completed.message, {
phase: "completed",
...attemptContext,
exitCode: result.exitCode,
...CODIFIER_EVENT_COPY.completed,
});
this.track("codifier_process_completed", startedAt, {
status: "completed",
Expand All @@ -133,16 +139,17 @@ export class CodifierProcessManager implements IProcessManager {
}

const retryExhausted = attempt === options.maxRetries;
this.emit(options, {
daemon: CODIFIER_EVENT_SOURCE,
status: retryExhausted ? "exhausted" : "skipped",
source: CODIFIER_EVENT_SOURCE,
goalId: goal.goalId,
attempt,
maxRetries: options.maxRetries,
events.emit(retryExhausted ? "exhausted" : "skipped", retryExhausted ? CODIFIER_EVENT_COPY.exhausted.category : CODIFIER_EVENT_COPY.skipped.category, retryExhausted ? CODIFIER_EVENT_COPY.exhausted.message : CODIFIER_EVENT_COPY.skipped.message, {
phase: retryExhausted ? "exhausted" : "retry",
...attemptContext,
exitCode: result.exitCode,
...(retryExhausted ? CODIFIER_EVENT_COPY.exhausted : CODIFIER_EVENT_COPY.skipped),
});
if (!retryExhausted) {
events.emit("processing", "retry", "retrying codification", {
phase: "retry",
...attemptContext,
});
}
}

this.track("codifier_process_completed", startedAt, {
Expand Down Expand Up @@ -176,21 +183,14 @@ export class CodifierProcessManager implements IProcessManager {
return goal?.status === GoalStatus.DONE;
}

private emit(options: CodifierProcessOptions, event: CodifierProcessEvent): void {
options.emit?.(event);
}

private emitFailure(
options: CodifierProcessOptions,
events: ProcessManagerEventEmitter,
goalId: string,
error: unknown,
): void {
this.emit(options, {
daemon: CODIFIER_EVENT_SOURCE,
status: "failed",
source: CODIFIER_EVENT_SOURCE,
events.emit("failed", CODIFIER_EVENT_COPY.failed.category, CODIFIER_EVENT_COPY.failed.message, {
phase: "failed",
goalId,
...CODIFIER_EVENT_COPY.failed,
...this.errorProperties(error),
});
}
Expand All @@ -207,6 +207,19 @@ export class CodifierProcessManager implements IProcessManager {
});
}

private emitReturnedAgentOutput(
events: ProcessManagerEventEmitter,
result: { readonly stdout?: string; readonly stderr?: string },
context: { readonly goalId: string; readonly attempt: number; readonly maxRetries: number },
): void {
if (result.stdout !== undefined) {
events.emitAgentActivity("stdout", result.stdout, context);
}
if (result.stderr !== undefined) {
events.emitAgentActivity("stderr", result.stderr, context);
}
}

private errorProperties(error: unknown): {
errorType: string;
errorMessage: string;
Expand Down
98 changes: 59 additions & 39 deletions src/application/context/goals/refine/RefinerProcessManager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { IAgentGateway } from "../../../agents/IAgentGateway.js";
import { IProcessManager, ProcessManagerEvent, ProcessManagerOptions, ProcessManagerResult } from "../../../daemons/IProcessManager.js";
import { IProcessManager, ProcessManagerOptions, ProcessManagerResult } from "../../../daemons/IProcessManager.js";
import { ProcessManagerEventEmitter } from "../../../daemons/ProcessManagerEventEmitter.js";
import { IWorkerIdentityReader } from "../../../host/workers/IWorkerIdentityReader.js";
import { ITelemetryClient } from "../../../telemetry/ITelemetryClient.js";
import { GoalStatus } from "../../../../domain/goals/Constants.js";
Expand Down Expand Up @@ -51,76 +52,82 @@ export class RefinerProcessManager implements IProcessManager {

async processNext(options: ProcessManagerOptions): Promise<ProcessManagerResult> {
const startedAt = process.hrtime.bigint();
const events = new ProcessManagerEventEmitter(REFINER_EVENT_SOURCE, options, startedAt);
events.emit("processing", "polling", "polling for defined goals", { phase: "polling" });
const goals = await this.selectEligibleGoals();

if (goals.length === 0) {
this.emit(options, {
daemon: REFINER_EVENT_SOURCE,
status: "idle",
source: REFINER_EVENT_SOURCE,
...REFINER_EVENT_COPY.noWork,
});
events.emit("idle", REFINER_EVENT_COPY.noWork.category, REFINER_EVENT_COPY.noWork.message, { phase: "idle" });
this.track(startedAt, { status: "idle", attempts: 0 });
return { status: "idle", attempts: 0 };
}

const goal = goals[0];
events.emit("processing", "selection", "selected goal for refinement", {
phase: "selection",
goalId: goal.goalId,
});

try {
await this.refineGoalController.handle({ goalId: goal.goalId });
} catch (error) {
const errorProperties = this.errorProperties(error);
this.emit(options, {
daemon: REFINER_EVENT_SOURCE,
status: "failed",
source: REFINER_EVENT_SOURCE,
events.emit("failed", REFINER_EVENT_COPY.failed.category, REFINER_EVENT_COPY.failed.message, {
phase: "failed",
goalId: goal.goalId,
...REFINER_EVENT_COPY.failed,
...errorProperties,
});
this.track(startedAt, { status: "failed", attempts: 0, goalId: goal.goalId, ...errorProperties });
return { status: "failed", goalId: goal.goalId, attempts: 0 };
}

for (let attempt = 1; attempt <= options.maxRetries; attempt++) {
this.emit(options, {
daemon: REFINER_EVENT_SOURCE,
status: "processing",
source: REFINER_EVENT_SOURCE,
const attemptContext = {
goalId: goal.goalId,
attempt,
maxRetries: options.maxRetries,
...REFINER_EVENT_COPY.workStarted,
};
events.emit("processing", REFINER_EVENT_COPY.workStarted.category, REFINER_EVENT_COPY.workStarted.message, {
phase: "working",
...attemptContext,
});
const result = await this.agentGateway.invoke({ agentId: options.agentId, prompt: this.buildPrompt(goal.goalId) });
let sawAgentActivity = false;
const invocation = {
agentId: options.agentId,
prompt: this.buildPrompt(goal.goalId),
onActivity: (activity: { readonly stream: "stdout" | "stderr"; readonly text: string }) => {
sawAgentActivity = true;
events.emitAgentActivity(activity.stream, activity.text, attemptContext);
},
};
const result = await this.agentGateway.invoke(invocation);
if (!sawAgentActivity) {
this.emitReturnedAgentOutput(events, result, attemptContext);
}

if (await this.isGoalRefined(goal.goalId)) {
this.emit(options, {
daemon: REFINER_EVENT_SOURCE,
status: "completed",
source: REFINER_EVENT_SOURCE,
goalId: goal.goalId,
attempt,
maxRetries: options.maxRetries,
events.emit("completed", REFINER_EVENT_COPY.completed.category, REFINER_EVENT_COPY.completed.message, {
phase: "completed",
...attemptContext,
exitCode: result.exitCode,
...REFINER_EVENT_COPY.completed,
});
this.track(startedAt, { status: "completed", attempts: attempt, goalId: goal.goalId, agentExitCode: result.exitCode });
return { status: "completed", goalId: goal.goalId, attempts: attempt };
}

const retryExhausted = attempt === options.maxRetries;
this.emit(options, {
daemon: REFINER_EVENT_SOURCE,
status: retryExhausted ? "exhausted" : "skipped",
source: REFINER_EVENT_SOURCE,
goalId: goal.goalId,
attempt,
maxRetries: options.maxRetries,
events.emit(retryExhausted ? "exhausted" : "skipped", retryExhausted ? REFINER_EVENT_COPY.exhausted.category : REFINER_EVENT_COPY.skipped.category, retryExhausted ? REFINER_EVENT_COPY.exhausted.message : REFINER_EVENT_COPY.skipped.message, {
phase: retryExhausted ? "exhausted" : "retry",
...attemptContext,
exitCode: result.exitCode,
...(retryExhausted ? REFINER_EVENT_COPY.exhausted : REFINER_EVENT_COPY.skipped),
...this.agentFailureProperties(result),
});
if (!retryExhausted) {
events.emit("processing", "retry", "retrying refinement", {
phase: "retry",
...attemptContext,
});
}
}

this.track(startedAt, { status: "exhausted", attempts: options.maxRetries, goalId: goal.goalId });
Expand All @@ -136,18 +143,18 @@ export class RefinerProcessManager implements IProcessManager {
}

buildPrompt(goalId: string): string {
return `Run the Jumbo refinement workflow for goal ${goalId}. Execute: jumbo goal refine --id ${goalId}`;
return [
`Continue the Jumbo refinement workflow for goal ${goalId}.`,
"The daemon has already moved the goal into refinement.",
`Update the goal context as needed, then complete refinement with: jumbo goal commit --id ${goalId}`,
].join(" ");
}

private async isGoalRefined(goalId: string): Promise<boolean> {
const goal = await this.goalReader.findById(goalId);
return goal?.status === GoalStatus.REFINED;
}

private emit(options: ProcessManagerOptions, event: ProcessManagerEvent): void {
options.emit?.(event);
}

private track(startedAt: bigint, properties: Record<string, unknown>): void {
this.telemetryClient.track("refiner_process_completed", {
daemon: "refiner",
Expand All @@ -156,6 +163,19 @@ export class RefinerProcessManager implements IProcessManager {
});
}

private emitReturnedAgentOutput(
events: ProcessManagerEventEmitter,
result: { readonly stdout?: string; readonly stderr?: string },
context: { readonly goalId: string; readonly attempt: number; readonly maxRetries: number },
): void {
if (result.stdout !== undefined) {
events.emitAgentActivity("stdout", result.stdout, context);
}
if (result.stderr !== undefined) {
events.emitAgentActivity("stderr", result.stderr, context);
}
}

private errorProperties(error: unknown): { errorType: string; errorMessage: string; errorStack?: string } {
if (error instanceof Error) {
return {
Expand Down
Loading
Loading