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: 1 addition & 1 deletion packages/subagents/src/runs/foreground/chain-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,9 +405,9 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
const shouldClarify = clarify !== false && ctx.hasUI && !hasParallelSteps;
let tuiBehaviorOverrides: (BehaviorOverride | undefined)[] | undefined;
const availableModels: ModelInfo[] = ctx.modelRegistry.getAvailable().map(toModelInfo);
const availableSkills = discoverAvailableSkills(cwd ?? ctx.cwd);

if (shouldClarify) {
const availableSkills = discoverAvailableSkills(cwd ?? ctx.cwd);
const seqSteps = chainSteps as SequentialStep[];
const agentConfigs: AgentConfig[] = [];
for (const step of seqSteps) {
Expand Down
7 changes: 7 additions & 0 deletions packages/workflows/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

## [Unreleased]

### Added

- Added main-chat lifecycle steer notices for workflow completion, failure, and awaiting-input pauses with global notification config controls ([#1085](https://github.com/flora131/atomic/issues/1085)).

### Fixed

- Escaped workflow lifecycle notice text and structured response hints, isolated lifecycle send failures from store subscribers, and rejected empty lifecycle notification event lists ([#1085](https://github.com/flora131/atomic/issues/1085)).
- Fixed stage awaiting-input lifecycle notice dedupe so promptless pauses after resolved prompts are not suppressed by historical prompt metadata ([#1085](https://github.com/flora131/atomic/issues/1085)).
- Reset workflow lifecycle-notification dedupe state at chat session boundaries so reused workflow run IDs in later sessions still emit completion/failure/input notices ([#1085](https://github.com/flora131/atomic/issues/1085)).
- Warn before starting or resuming another session when workflows are still in flight, allowing users to cancel before those runs are killed and current-session workflow history is cleared ([#1082](https://github.com/flora131/atomic/issues/1082)).
- Prevented workflow stage sessions from exposing or executing the `workflow` tool while preserving stage-level subagent delegation.
- Retained completed, failed, and killed workflow runs in user-facing status/connect surfaces and changed workflow kill controls to mark runs killed without removing them from live inspection history ([#1083](https://github.com/flora131/atomic/issues/1083)).
Expand Down
19 changes: 19 additions & 0 deletions packages/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,21 @@ Adding workflow files under `.atomic/workflows/` (project scope) or `~/.atomic/a
}
```

### Workflow lifecycle notifications

Workflow lifecycle notices are enabled by default. They send steer prompts into the main chat/model context when a run completes, fails, or pauses for input. Configure them in the same extension config file:

```json
{
"workflowNotifications": {
"enabled": true,
"notifyOn": ["completed", "failed", "awaiting_input"]
}
}
```

Set `enabled` to `false` to disable all notices, or narrow `notifyOn` to a non-empty list of selected events. Emitted notices use steer delivery and wake an idle model so the lifecycle update enters the model context when it happens.

---

## Authoring API
Expand Down Expand Up @@ -429,6 +444,10 @@ Config-based discovery (`~/.atomic/agent/extensions/workflow/config.json` or `.a
{
"workflows": {
"my-team-workflows": { "path": "/shared/team/workflows" }
},
"workflowNotifications": {
"enabled": true,
"notifyOn": ["completed", "failed", "awaiting_input"]
}
}
```
Expand Down
68 changes: 68 additions & 0 deletions packages/workflows/src/extension/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
import { join, isAbsolute } from "node:path";
import { homedir } from "node:os";
import { CONFIG_DIR_NAME, CONFIG_DIR_NAMES, getProjectConfigPaths } from "@bastani/atomic";
import {
WORKFLOW_LIFECYCLE_NOTICE_KINDS,
type WorkflowLifecycleNoticeKind,
} from "./lifecycle-notifications.js";

const WORKFLOW_LIFECYCLE_NOTICE_KIND_SET = new Set<string>(WORKFLOW_LIFECYCLE_NOTICE_KINDS);

// ---------------------------------------------------------------------------
// Public types
Expand All @@ -34,6 +40,13 @@ export interface WorkflowConfigEntry {
* The parsed shape of a workflow extension config file.
* All fields optional; absence means "use default".
*/
export interface WorkflowNotificationsConfig {
/** Emit lifecycle notices into the main chat. Default: true. */
readonly enabled?: boolean;
/** Lifecycle states that should create chat notices. */
readonly notifyOn?: readonly WorkflowLifecycleNoticeKind[];
}

export interface WorkflowExtensionConfig {
/** Explicit named workflows to register by module path. */
readonly workflows?: Readonly<Record<string, WorkflowConfigEntry>>;
Expand All @@ -47,6 +60,8 @@ export interface WorkflowExtensionConfig {
readonly statusFile?: boolean;
/** Behaviour on session_start for in-flight runs. Default: "ask". */
readonly resumeInFlight?: "ask" | "auto" | "never";
/** Main-chat workflow lifecycle notices. */
readonly workflowNotifications?: WorkflowNotificationsConfig;
}

/** Severity of a config diagnostic. */
Expand Down Expand Up @@ -126,6 +141,10 @@ async function tryReadFile(filePath: string): Promise<string | null> {
* Validate a parsed JSON value as a WorkflowExtensionConfig.
* Returns null when valid, or a human-readable rejection reason.
*/
function isWorkflowLifecycleNoticeKind(value: unknown): value is WorkflowLifecycleNoticeKind {
return typeof value === "string" && WORKFLOW_LIFECYCLE_NOTICE_KIND_SET.has(value);
}

function validateConfig(value: unknown): string | null {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return "config must be a JSON object";
Expand Down Expand Up @@ -155,6 +174,31 @@ function validateConfig(value: unknown): string | null {
}
}

if ("workflowNotifications" in c) {
const value = c["workflowNotifications"];
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return `"workflowNotifications" must be a JSON object, got ${JSON.stringify(typeof value)}`;
}
const notifications = value as Record<string, unknown>;
if ("enabled" in notifications && typeof notifications["enabled"] !== "boolean") {
return `"workflowNotifications.enabled" must be a boolean, got ${JSON.stringify(notifications["enabled"])}`;
}
if ("notifyOn" in notifications) {
const notifyOn = notifications["notifyOn"];
if (!Array.isArray(notifyOn)) {
return `"workflowNotifications.notifyOn" must be an array, got ${JSON.stringify(typeof notifyOn)}`;
}
if (notifyOn.length === 0) {
return `"workflowNotifications.notifyOn" must be a non-empty array`;
}
for (const item of notifyOn) {
if (!isWorkflowLifecycleNoticeKind(item)) {
return `"workflowNotifications.notifyOn" entries must be "completed", "failed", or "awaiting_input", got ${JSON.stringify(item)}`;
}
}
}
}

if ("workflows" in c) {
if (c["workflows"] === null || typeof c["workflows"] !== "object" || Array.isArray(c["workflows"])) {
return `"workflows" must be a JSON object, got ${JSON.stringify(typeof c["workflows"])}`;
Expand Down Expand Up @@ -268,6 +312,14 @@ function mergeConfigs(
...(base.resumeInFlight !== undefined || override.resumeInFlight !== undefined
? { resumeInFlight: override.resumeInFlight ?? base.resumeInFlight }
: {}),
...(base.workflowNotifications !== undefined || override.workflowNotifications !== undefined
? {
workflowNotifications: {
...(base.workflowNotifications ?? {}),
...(override.workflowNotifications ?? {}),
},
}
: {}),
...(workflows !== undefined ? { workflows } : {}),
};
}
Expand All @@ -289,6 +341,10 @@ export const WORKFLOW_CONFIG_DEFAULTS = {
persistRuns: true,
statusFile: false,
resumeInFlight: "ask" as const,
workflowNotifications: {
enabled: true,
notifyOn: ["completed", "failed", "awaiting_input"] as const,
},
} as const;

/**
Expand All @@ -301,6 +357,10 @@ export interface WorkflowEffectiveConfig {
readonly persistRuns: boolean;
readonly statusFile: boolean;
readonly resumeInFlight: "ask" | "auto" | "never";
readonly workflowNotifications: {
readonly enabled: boolean;
readonly notifyOn: readonly WorkflowLifecycleNoticeKind[];
};
readonly workflows?: Readonly<Record<string, WorkflowConfigEntry>>;
}

Expand All @@ -321,6 +381,14 @@ export function withWorkflowDefaults(
statusFile: config.statusFile ?? WORKFLOW_CONFIG_DEFAULTS.statusFile,
resumeInFlight:
config.resumeInFlight ?? WORKFLOW_CONFIG_DEFAULTS.resumeInFlight,
workflowNotifications: {
enabled:
config.workflowNotifications?.enabled
?? WORKFLOW_CONFIG_DEFAULTS.workflowNotifications.enabled,
notifyOn:
config.workflowNotifications?.notifyOn
?? WORKFLOW_CONFIG_DEFAULTS.workflowNotifications.notifyOn,
},
...(config.workflows !== undefined ? { workflows: config.workflows } : {}),
};
}
Expand Down
76 changes: 69 additions & 7 deletions packages/workflows/src/extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ import {
WORKFLOW_CONFIG_DEFAULTS,
withWorkflowDefaults,
} from "./config-loader.js";
import {
createWorkflowLifecycleNotificationState,
installWorkflowLifecycleNotifications,
registerLifecycleNoticeRenderer,
resetWorkflowLifecycleNotificationState,
seedWorkflowLifecycleNotificationState,
withWorkflowLifecycleNotificationsSuppressed,
} from "./lifecycle-notifications.js";
import type { WorkflowLifecycleNotificationConfig } from "./lifecycle-notifications.js";
import type { ConfigLoadResult } from "./config-loader.js";
import type {
WorkflowPersistencePort,
Expand Down Expand Up @@ -113,6 +122,13 @@ export interface PiRenderComponent {
includes(searchString: string): boolean;
}

export interface PiMessageRenderComponent {
render(width: number): string[];
invalidate?: () => void;
}

export type PiMessageRendererResult = string | PiMessageRenderComponent | undefined;

function textRenderComponent(text: string): PiRenderComponent {
return dynamicTextRenderComponent(() => text);
}
Expand Down Expand Up @@ -273,7 +289,7 @@ export interface ExtensionAPI {
registerCommand?: (name: string, options: PiCommandOptions) => void;
registerMessageRenderer?: (
event: string,
renderer: (payload: unknown) => string,
renderer: (payload: unknown) => PiMessageRendererResult,
) => void;
/**
* Inject a custom message into chat history. Used by inline workflow surfaces
Expand Down Expand Up @@ -1977,6 +1993,32 @@ function factory(pi: ExtensionAPI): void {
store,
runtimeConfigRef.current,
);
let lifecycleNotificationsUnsubscribe: (() => void) | null = null;
let lifecycleNotificationsActive = false;
const lifecycleNotificationState = createWorkflowLifecycleNotificationState();
const lifecycleNotificationConfigRef: { current: WorkflowLifecycleNotificationConfig } = {
current: WORKFLOW_CONFIG_DEFAULTS.workflowNotifications,
};
registerLifecycleNoticeRenderer({
rendererHost: pi,
registerMessageRenderer: pi.registerMessageRenderer
? (event, renderer) => pi.registerMessageRenderer?.(event, renderer)
: undefined,
});
const reinstallLifecycleNotifications = (): void => {
lifecycleNotificationsUnsubscribe?.();
lifecycleNotificationsUnsubscribe = null;
if (!lifecycleNotificationsActive) return;
lifecycleNotificationsUnsubscribe = installWorkflowLifecycleNotifications({
store,
config: lifecycleNotificationConfigRef.current,
state: lifecycleNotificationState,
seedExisting: true,
sendMessage: pi.sendMessage
? (message, options) => pi.sendMessage?.(message, options)
: undefined,
});
};
let intercomParentSession: string | null = null;
const intercomPort = {
emit:
Expand Down Expand Up @@ -2127,6 +2169,8 @@ function factory(pi: ExtensionAPI): void {
statusFile: effectiveConfig.statusFile,
resumeInFlight: effectiveConfig.resumeInFlight,
};
lifecycleNotificationConfigRef.current = effectiveConfig.workflowNotifications;
reinstallLifecycleNotifications();

// Replace status writer with one that reflects the resolved config.
// Unsubscribe the prior (no-op) writer before creating the new one.
Expand Down Expand Up @@ -3333,6 +3377,7 @@ function factory(pi: ExtensionAPI): void {
persistence: persistenceRef.current,
});
store.clear();
resetWorkflowLifecycleNotificationState(lifecycleNotificationState);
stageControlRegistry.clear();

// pi-intercom session naming lives here so we don't trip the
Expand All @@ -3343,6 +3388,8 @@ function factory(pi: ExtensionAPI): void {
// Ensure config+discovery are ready before restoring in-flight runs —
// tunables must be resolved first.
await discoveryPromise;
lifecycleNotificationsActive = true;
reinstallLifecycleNotifications();
if (ctx?.ui) {
const diagnostics = formatStartupDiagnostics(configLoadRef.current, discoveryRef.current);
if (diagnostics !== null) {
Expand All @@ -3355,13 +3402,25 @@ function factory(pi: ExtensionAPI): void {
const sessionManager = ctx?.sessionManager ?? pi.sessionManager;
if (sessionManager) {
const cfg = configLoadRef.current?.config;
restoreOnSessionStart(
sessionManager,
{
resumeInFlight: cfg?.resumeInFlight ?? "ask",
persistRuns: cfg?.persistRuns ?? true,
withWorkflowLifecycleNotificationsSuppressed(
lifecycleNotificationState,
() => {
restoreOnSessionStart(
sessionManager,
{
resumeInFlight: cfg?.resumeInFlight ?? "ask",
persistRuns: cfg?.persistRuns ?? true,
},
store,
);
// The suppressed subscriber observes restore replay and marks matching
// notices delivered. Seed explicitly as a defensive backstop for
// runtimes without a lifecycle-notification subscriber installed.
seedWorkflowLifecycleNotificationState(
lifecycleNotificationState,
store.snapshot(),
);
},
store,
);
}
});
Expand All @@ -3385,6 +3444,9 @@ function factory(pi: ExtensionAPI): void {
}
storeWidgetUnsubscribe?.();
storeWidgetUnsubscribe = null;
lifecycleNotificationsActive = false;
lifecycleNotificationsUnsubscribe?.();
lifecycleNotificationsUnsubscribe = null;
});
}

Expand Down
Loading
Loading