sequant 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +8 -5
- package/dist/bin/cli.js +46 -4
- package/dist/src/commands/abort.d.ts +36 -0
- package/dist/src/commands/abort.js +138 -0
- package/dist/src/commands/prompt.d.ts +7 -0
- package/dist/src/commands/prompt.js +101 -7
- package/dist/src/commands/run-progress.d.ts +11 -1
- package/dist/src/commands/run-progress.js +20 -3
- package/dist/src/commands/run.js +12 -2
- package/dist/src/commands/watch.d.ts +2 -0
- package/dist/src/commands/watch.js +67 -3
- package/dist/src/lib/assess-collision-detect.js +1 -1
- package/dist/src/lib/cli-ui/run-renderer-types.d.ts +39 -0
- package/dist/src/lib/cli-ui/run-renderer.d.ts +27 -1
- package/dist/src/lib/cli-ui/run-renderer.js +231 -14
- package/dist/src/lib/cli-ui/scrollback-harness.d.ts +112 -0
- package/dist/src/lib/cli-ui/scrollback-harness.js +294 -0
- package/dist/src/lib/merge-check/types.js +1 -1
- package/dist/src/lib/relay/archive.js +6 -0
- package/dist/src/lib/relay/types.d.ts +2 -0
- package/dist/src/lib/relay/types.js +9 -0
- package/dist/src/lib/workflow/batch-executor.js +34 -18
- package/dist/src/lib/workflow/drivers/agent-driver.d.ts +48 -1
- package/dist/src/lib/workflow/drivers/aider.d.ts +7 -1
- package/dist/src/lib/workflow/drivers/aider.js +9 -0
- package/dist/src/lib/workflow/drivers/claude-code.d.ts +17 -1
- package/dist/src/lib/workflow/drivers/claude-code.js +51 -2
- package/dist/src/lib/workflow/drivers/index.d.ts +1 -1
- package/dist/src/lib/workflow/event-emitter.d.ts +157 -0
- package/dist/src/lib/workflow/event-emitter.js +102 -0
- package/dist/src/lib/workflow/notice.d.ts +32 -0
- package/dist/src/lib/workflow/notice.js +38 -0
- package/dist/src/lib/workflow/phase-executor.d.ts +9 -21
- package/dist/src/lib/workflow/phase-executor.js +88 -115
- package/dist/src/lib/workflow/phase-mapper.d.ts +26 -13
- package/dist/src/lib/workflow/phase-mapper.js +55 -33
- package/dist/src/lib/workflow/phase-registry.d.ts +127 -0
- package/dist/src/lib/workflow/phase-registry.js +233 -0
- package/dist/src/lib/workflow/run-log-schema.d.ts +5 -55
- package/dist/src/lib/workflow/run-orchestrator.d.ts +32 -2
- package/dist/src/lib/workflow/run-orchestrator.js +125 -11
- package/dist/src/lib/workflow/state-manager.d.ts +19 -1
- package/dist/src/lib/workflow/state-manager.js +27 -1
- package/dist/src/lib/workflow/state-schema.d.ts +20 -35
- package/dist/src/lib/workflow/state-schema.js +28 -3
- package/dist/src/lib/workflow/types.d.ts +65 -15
- package/dist/src/lib/workflow/types.js +18 -13
- package/package.json +5 -4
- package/templates/hooks/post-tool.sh +81 -0
- package/templates/skills/assess/SKILL.md +28 -28
- package/templates/skills/assess/references/predicted-collision-detection.md +1 -1
- package/templates/skills/setup/SKILL.md +6 -6
|
@@ -14,6 +14,15 @@ export class AiderDriver {
|
|
|
14
14
|
constructor(settings) {
|
|
15
15
|
this.settings = settings;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Aider has no session-resume concept: each invocation is a one-shot
|
|
19
|
+
* `aider --message <prompt>` against a fresh chat. There is no token to
|
|
20
|
+
* reattach to, so resume is always declined (#674).
|
|
21
|
+
*/
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
23
|
+
canResume(handle, targetCwd) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
17
26
|
async executePhase(prompt, config) {
|
|
18
27
|
const args = this.buildArgs(prompt, config.files);
|
|
19
28
|
return new Promise((resolve) => {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Owns the `@anthropic-ai/claude-agent-sdk` import. No other file in the
|
|
5
5
|
* orchestration layer should import the SDK directly.
|
|
6
6
|
*/
|
|
7
|
-
import type { AgentDriver, AgentExecutionConfig, AgentPhaseResult } from "./agent-driver.js";
|
|
7
|
+
import type { AgentDriver, AgentExecutionConfig, AgentPhaseResult, ResumeHandle } from "./agent-driver.js";
|
|
8
8
|
export declare class ClaudeCodeDriver implements AgentDriver {
|
|
9
9
|
name: string;
|
|
10
10
|
/**
|
|
@@ -12,6 +12,22 @@ export declare class ClaudeCodeDriver implements AgentDriver {
|
|
|
12
12
|
* Set after each executePhase() call.
|
|
13
13
|
*/
|
|
14
14
|
private lastSessionId?;
|
|
15
|
+
/**
|
|
16
|
+
* Decide whether a resume handle can be used for a target cwd.
|
|
17
|
+
*
|
|
18
|
+
* Claude Code namespaces session storage by encoded cwd
|
|
19
|
+
* (`~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`), so a session
|
|
20
|
+
* created in cwd A cannot be located when resuming from cwd B — the SDK
|
|
21
|
+
* returns `error_during_execution` ("No conversation found") rather than
|
|
22
|
+
* crashing (verified against `@anthropic-ai/claude-agent-sdk@0.3.142`,
|
|
23
|
+
* see #674).
|
|
24
|
+
*
|
|
25
|
+
* We use byte-equal comparison, not a normalized path: the SDK's storage
|
|
26
|
+
* key is derived from the literal cwd string, so normalizing here would
|
|
27
|
+
* risk false-positive resumes whose token would then miss on disk.
|
|
28
|
+
*/
|
|
29
|
+
canResume(handle: ResumeHandle, targetCwd: string): boolean;
|
|
15
30
|
executePhase(prompt: string, config: AgentExecutionConfig): Promise<AgentPhaseResult>;
|
|
31
|
+
private buildResumeHandle;
|
|
16
32
|
isAvailable(): Promise<boolean>;
|
|
17
33
|
}
|
|
@@ -14,6 +14,25 @@ export class ClaudeCodeDriver {
|
|
|
14
14
|
* Set after each executePhase() call.
|
|
15
15
|
*/
|
|
16
16
|
lastSessionId;
|
|
17
|
+
/**
|
|
18
|
+
* Decide whether a resume handle can be used for a target cwd.
|
|
19
|
+
*
|
|
20
|
+
* Claude Code namespaces session storage by encoded cwd
|
|
21
|
+
* (`~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`), so a session
|
|
22
|
+
* created in cwd A cannot be located when resuming from cwd B — the SDK
|
|
23
|
+
* returns `error_during_execution` ("No conversation found") rather than
|
|
24
|
+
* crashing (verified against `@anthropic-ai/claude-agent-sdk@0.3.142`,
|
|
25
|
+
* see #674).
|
|
26
|
+
*
|
|
27
|
+
* We use byte-equal comparison, not a normalized path: the SDK's storage
|
|
28
|
+
* key is derived from the literal cwd string, so normalizing here would
|
|
29
|
+
* risk false-positive resumes whose token would then miss on disk.
|
|
30
|
+
*/
|
|
31
|
+
canResume(handle, targetCwd) {
|
|
32
|
+
if (handle.driver !== this.name)
|
|
33
|
+
return false;
|
|
34
|
+
return handle.originCwd === targetCwd;
|
|
35
|
+
}
|
|
17
36
|
async executePhase(prompt, config) {
|
|
18
37
|
const abortController = new AbortController();
|
|
19
38
|
// Wire external abort signal
|
|
@@ -30,6 +49,23 @@ export class ClaudeCodeDriver {
|
|
|
30
49
|
let capturedStderr = "";
|
|
31
50
|
const stderrBuffer = new RingBuffer(50);
|
|
32
51
|
const stdoutBuffer = new RingBuffer(50);
|
|
52
|
+
// Resolve resume token with cwd-safety check.
|
|
53
|
+
//
|
|
54
|
+
// Prefer the driver-tagged `resumeHandle` over the legacy `sessionId`
|
|
55
|
+
// string (#674). On cwd mismatch we silently drop the resume — Claude
|
|
56
|
+
// Code's cwd-mismatched resume is recoverable (`error_during_execution`,
|
|
57
|
+
// "No conversation found"), but starting fresh is the cleaner outcome
|
|
58
|
+
// than surfacing a per-phase error the caller can't act on.
|
|
59
|
+
let resumeToken;
|
|
60
|
+
if (config.resumeHandle &&
|
|
61
|
+
this.canResume(config.resumeHandle, config.cwd)) {
|
|
62
|
+
resumeToken = config.resumeHandle.token;
|
|
63
|
+
}
|
|
64
|
+
else if (!config.resumeHandle && config.sessionId) {
|
|
65
|
+
// Back-compat: legacy state (sessionId without originCwd) cannot prove
|
|
66
|
+
// cwd parity. Per #674's fail-safe rule, do NOT resume — start fresh.
|
|
67
|
+
resumeToken = undefined;
|
|
68
|
+
}
|
|
33
69
|
try {
|
|
34
70
|
// Get MCP servers config if enabled
|
|
35
71
|
const mcpServers = config.mcp ? getMcpServersConfig() : undefined;
|
|
@@ -43,8 +79,8 @@ export class ClaudeCodeDriver {
|
|
|
43
79
|
tools: { type: "preset", preset: "claude_code" },
|
|
44
80
|
permissionMode: "bypassPermissions",
|
|
45
81
|
allowDangerouslySkipPermissions: true,
|
|
46
|
-
// Resume from previous session
|
|
47
|
-
...(
|
|
82
|
+
// Resume from previous session only when cwd matches origin (#674).
|
|
83
|
+
...(resumeToken ? { resume: resumeToken } : {}),
|
|
48
84
|
env: config.env,
|
|
49
85
|
...(mcpServers ? { mcpServers } : {}),
|
|
50
86
|
stderr: (data) => {
|
|
@@ -84,12 +120,17 @@ export class ClaudeCodeDriver {
|
|
|
84
120
|
}
|
|
85
121
|
clearTimeout(timeoutId);
|
|
86
122
|
this.lastSessionId = resultSessionId;
|
|
123
|
+
// Build the cwd-bound resume handle from the session created in
|
|
124
|
+
// `config.cwd`. `sessionId` is mirrored for one release (#674) so
|
|
125
|
+
// upgraded callers can still drive resume off the deprecated field.
|
|
126
|
+
const resumeHandle = this.buildResumeHandle(resultSessionId, config.cwd);
|
|
87
127
|
if (resultMessage) {
|
|
88
128
|
if (resultMessage.subtype === "success") {
|
|
89
129
|
return {
|
|
90
130
|
success: true,
|
|
91
131
|
output: capturedOutput,
|
|
92
132
|
sessionId: resultSessionId,
|
|
133
|
+
resumeHandle,
|
|
93
134
|
stderrTail: stderrBuffer.getLines(),
|
|
94
135
|
stdoutTail: stdoutBuffer.getLines(),
|
|
95
136
|
};
|
|
@@ -113,6 +154,7 @@ export class ClaudeCodeDriver {
|
|
|
113
154
|
success: false,
|
|
114
155
|
output: capturedOutput,
|
|
115
156
|
sessionId: resultSessionId,
|
|
157
|
+
resumeHandle,
|
|
116
158
|
error,
|
|
117
159
|
stderrTail: stderrBuffer.getLines(),
|
|
118
160
|
stdoutTail: stdoutBuffer.getLines(),
|
|
@@ -122,6 +164,7 @@ export class ClaudeCodeDriver {
|
|
|
122
164
|
success: false,
|
|
123
165
|
output: capturedOutput,
|
|
124
166
|
sessionId: resultSessionId,
|
|
167
|
+
resumeHandle,
|
|
125
168
|
error: "No result received from Claude",
|
|
126
169
|
stderrTail: stderrBuffer.getLines(),
|
|
127
170
|
stdoutTail: stdoutBuffer.getLines(),
|
|
@@ -146,12 +189,18 @@ export class ClaudeCodeDriver {
|
|
|
146
189
|
success: false,
|
|
147
190
|
output: capturedOutput,
|
|
148
191
|
sessionId: resultSessionId,
|
|
192
|
+
resumeHandle: this.buildResumeHandle(resultSessionId, config.cwd),
|
|
149
193
|
error: error + stderrSuffix,
|
|
150
194
|
stderrTail: stderrBuffer.getLines(),
|
|
151
195
|
stdoutTail: stdoutBuffer.getLines(),
|
|
152
196
|
};
|
|
153
197
|
}
|
|
154
198
|
}
|
|
199
|
+
buildResumeHandle(token, originCwd) {
|
|
200
|
+
if (!token)
|
|
201
|
+
return undefined;
|
|
202
|
+
return { driver: this.name, token, originCwd };
|
|
203
|
+
}
|
|
155
204
|
async isAvailable() {
|
|
156
205
|
try {
|
|
157
206
|
// If we can import the SDK, it's available
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { AgentDriver } from "./agent-driver.js";
|
|
8
8
|
import type { AiderSettings } from "../../settings.js";
|
|
9
|
-
export type { AgentDriver, AgentExecutionConfig, AgentPhaseResult, } from "./agent-driver.js";
|
|
9
|
+
export type { AgentDriver, AgentExecutionConfig, AgentPhaseResult, ResumeHandle, } from "./agent-driver.js";
|
|
10
10
|
export interface DriverOptions {
|
|
11
11
|
aiderSettings?: AiderSettings;
|
|
12
12
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed workflow event emitter (#504).
|
|
3
|
+
*
|
|
4
|
+
* Provides a strongly-typed `EventEmitter` for `RunOrchestrator` lifecycle
|
|
5
|
+
* events. Multiple consumers (TUI, MCP server, future webhooks) can subscribe
|
|
6
|
+
* without the orchestrator knowing they exist.
|
|
7
|
+
*
|
|
8
|
+
* Design notes:
|
|
9
|
+
* - Wraps Node's built-in `node:events.EventEmitter` (no third-party deps per
|
|
10
|
+
* AC-2). The typing is compile-time only — at runtime this is a vanilla
|
|
11
|
+
* `EventEmitter`.
|
|
12
|
+
* - `emit()` is overridden to invoke listeners through `Promise.allSettled`
|
|
13
|
+
* so a slow or throwing listener can never crash the pipeline (AC-5). The
|
|
14
|
+
* override returns `Promise<void>` so callers in critical paths can `await`
|
|
15
|
+
* the wrapper; fire-and-forget callers (e.g. `progress` ticks) just drop
|
|
16
|
+
* the returned promise.
|
|
17
|
+
* - The orchestrator coexists with the existing `onProgress` callback rather
|
|
18
|
+
* than replacing it. `onProgress` remains the synchronous TUI render hook;
|
|
19
|
+
* events are the async multi-subscriber surface. Consolidation is an
|
|
20
|
+
* intentional non-goal (see issue #504, descope comment 2026-04-09).
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
import type { QaVerdict } from "./run-log-schema.js";
|
|
25
|
+
/** Issue lifecycle status surfaced through `issue_status_changed`. */
|
|
26
|
+
export type IssueEventStatus = "queued" | "running" | "passed" | "failed";
|
|
27
|
+
/**
|
|
28
|
+
* Base fields present on every workflow event payload.
|
|
29
|
+
*
|
|
30
|
+
* Payloads are JSON-serializable: only primitives and plain records, no
|
|
31
|
+
* class instances or circular references. This keeps MCP/webhook consumers
|
|
32
|
+
* cheap (`JSON.stringify(payload)` is always safe).
|
|
33
|
+
*/
|
|
34
|
+
export interface BaseEventPayload {
|
|
35
|
+
/** GitHub issue number this event is about. */
|
|
36
|
+
issueNumber: number;
|
|
37
|
+
/** ISO 8601 timestamp captured when the event was emitted. */
|
|
38
|
+
timestamp: string;
|
|
39
|
+
}
|
|
40
|
+
/** Payload for `run_started` / `run_completed`. */
|
|
41
|
+
export interface RunEventPayload extends BaseEventPayload {
|
|
42
|
+
/** Wall-clock duration in seconds. Present on `run_completed` only. */
|
|
43
|
+
duration?: number;
|
|
44
|
+
/** Aggregate success across all phases. Present on `run_completed` only. */
|
|
45
|
+
success?: boolean;
|
|
46
|
+
}
|
|
47
|
+
/** Payload for `phase_started`. */
|
|
48
|
+
export interface PhaseStartedPayload extends BaseEventPayload {
|
|
49
|
+
/** Phase name (e.g. `spec`, `exec`, `qa`). */
|
|
50
|
+
phase: string;
|
|
51
|
+
/** Outer-loop iteration (1-based). Present when running under quality-loop. */
|
|
52
|
+
iteration?: number;
|
|
53
|
+
}
|
|
54
|
+
/** Payload for `phase_completed`. */
|
|
55
|
+
export interface PhaseCompletedPayload extends BaseEventPayload {
|
|
56
|
+
phase: string;
|
|
57
|
+
/** Phase wall-clock duration in seconds (matches `PhaseLog.durationSeconds`). */
|
|
58
|
+
duration: number;
|
|
59
|
+
iteration?: number;
|
|
60
|
+
}
|
|
61
|
+
/** Payload for `phase_failed`. */
|
|
62
|
+
export interface PhaseFailedPayload extends BaseEventPayload {
|
|
63
|
+
phase: string;
|
|
64
|
+
duration?: number;
|
|
65
|
+
/** Stringified error message. Class instances are flattened to strings. */
|
|
66
|
+
error: string;
|
|
67
|
+
iteration?: number;
|
|
68
|
+
}
|
|
69
|
+
/** Payload for `issue_status_changed`. */
|
|
70
|
+
export interface IssueStatusChangedPayload extends BaseEventPayload {
|
|
71
|
+
/** Previous lifecycle status. */
|
|
72
|
+
from: IssueEventStatus;
|
|
73
|
+
/** New lifecycle status. */
|
|
74
|
+
to: IssueEventStatus;
|
|
75
|
+
}
|
|
76
|
+
/** Payload for `qa_verdict`. Emitted once per QA phase that produces a verdict. */
|
|
77
|
+
export interface QaVerdictPayload extends BaseEventPayload {
|
|
78
|
+
phase: "qa";
|
|
79
|
+
verdict: QaVerdict;
|
|
80
|
+
}
|
|
81
|
+
/** Payload for `progress` (sub-phase activity ping, ~10 Hz). */
|
|
82
|
+
export interface ProgressPayload extends BaseEventPayload {
|
|
83
|
+
phase: string;
|
|
84
|
+
/** Short one-line snippet for the dashboard activity row. */
|
|
85
|
+
text?: string;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Discriminated map of event name → payload type. Add new events by
|
|
89
|
+
* extending this map; the `emit`/`on`/`off` overloads below pick them up
|
|
90
|
+
* automatically. Reject `string` index signatures so consumers cannot
|
|
91
|
+
* subscribe to typo'd event names without a TypeScript error (AC-1, AC-2).
|
|
92
|
+
*
|
|
93
|
+
* Event names use `snake_case` intentionally — they match the issue body and
|
|
94
|
+
* common JSON-payload conventions, and they are part of the public contract
|
|
95
|
+
* for MCP / webhook consumers. Payload field names stay `camelCase`. Do not
|
|
96
|
+
* "normalize" the event-name casing.
|
|
97
|
+
*/
|
|
98
|
+
export interface WorkflowEvents {
|
|
99
|
+
run_started: RunEventPayload;
|
|
100
|
+
run_completed: RunEventPayload;
|
|
101
|
+
phase_started: PhaseStartedPayload;
|
|
102
|
+
phase_completed: PhaseCompletedPayload;
|
|
103
|
+
phase_failed: PhaseFailedPayload;
|
|
104
|
+
issue_status_changed: IssueStatusChangedPayload;
|
|
105
|
+
qa_verdict: QaVerdictPayload;
|
|
106
|
+
progress: ProgressPayload;
|
|
107
|
+
}
|
|
108
|
+
/** Listener signature for a given event name. */
|
|
109
|
+
export type WorkflowEventListener<E extends keyof WorkflowEvents> = (payload: WorkflowEvents[E]) => void | Promise<void>;
|
|
110
|
+
/**
|
|
111
|
+
* Build an ISO 8601 timestamp for an event payload. Centralized so tests can
|
|
112
|
+
* inject a fixed clock via the constructor.
|
|
113
|
+
*
|
|
114
|
+
* @internal
|
|
115
|
+
*/
|
|
116
|
+
export type Clock = () => Date;
|
|
117
|
+
/**
|
|
118
|
+
* Typed wrapper around `node:events.EventEmitter`.
|
|
119
|
+
*
|
|
120
|
+
* Type parameter `E` is the event-name → payload map. `on`, `off`, and
|
|
121
|
+
* `emit` are all narrowed to that map; passing an unknown event name fails
|
|
122
|
+
* compilation (AC-2).
|
|
123
|
+
*/
|
|
124
|
+
export declare class WorkflowEventEmitter {
|
|
125
|
+
private readonly inner;
|
|
126
|
+
private readonly clock;
|
|
127
|
+
private readonly onListenerError;
|
|
128
|
+
constructor(opts?: {
|
|
129
|
+
clock?: Clock;
|
|
130
|
+
/**
|
|
131
|
+
* Invoked whenever a listener throws or rejects. Defaults to a no-op
|
|
132
|
+
* because rejection logging is the orchestrator's call (it knows whether
|
|
133
|
+
* `verbose` is enabled). Tests use this to assert isolation.
|
|
134
|
+
*/
|
|
135
|
+
onListenerError?: (eventName: string, error: unknown) => void;
|
|
136
|
+
});
|
|
137
|
+
/** Register a listener for the given event. */
|
|
138
|
+
on<E extends keyof WorkflowEvents>(event: E, listener: WorkflowEventListener<E>): this;
|
|
139
|
+
/** Remove a previously registered listener. */
|
|
140
|
+
off<E extends keyof WorkflowEvents>(event: E, listener: WorkflowEventListener<E>): this;
|
|
141
|
+
/** Number of listeners attached to `event`. Useful in tests. */
|
|
142
|
+
listenerCount<E extends keyof WorkflowEvents>(event: E): number;
|
|
143
|
+
/** Drop all listeners. Call from `RunOrchestrator` teardown to avoid leaks. */
|
|
144
|
+
removeAllListeners(): this;
|
|
145
|
+
/**
|
|
146
|
+
* Emit an event. Listeners are invoked through `Promise.allSettled` so a
|
|
147
|
+
* single misbehaving subscriber cannot crash the pipeline (AC-5).
|
|
148
|
+
*
|
|
149
|
+
* Returns `Promise<void>` so critical-path callers may `await`. Fire-and-
|
|
150
|
+
* forget callers (`progress`, etc.) ignore the returned promise — the
|
|
151
|
+
* `Promise.allSettled` swallowing handles unhandled rejection warnings.
|
|
152
|
+
*
|
|
153
|
+
* The `timestamp` field on the payload is populated automatically when
|
|
154
|
+
* absent so call sites stay terse.
|
|
155
|
+
*/
|
|
156
|
+
emit<E extends keyof WorkflowEvents>(event: E, payload: Omit<WorkflowEvents[E], "timestamp"> & Partial<Pick<WorkflowEvents[E], "timestamp">>): Promise<void>;
|
|
157
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed workflow event emitter (#504).
|
|
3
|
+
*
|
|
4
|
+
* Provides a strongly-typed `EventEmitter` for `RunOrchestrator` lifecycle
|
|
5
|
+
* events. Multiple consumers (TUI, MCP server, future webhooks) can subscribe
|
|
6
|
+
* without the orchestrator knowing they exist.
|
|
7
|
+
*
|
|
8
|
+
* Design notes:
|
|
9
|
+
* - Wraps Node's built-in `node:events.EventEmitter` (no third-party deps per
|
|
10
|
+
* AC-2). The typing is compile-time only — at runtime this is a vanilla
|
|
11
|
+
* `EventEmitter`.
|
|
12
|
+
* - `emit()` is overridden to invoke listeners through `Promise.allSettled`
|
|
13
|
+
* so a slow or throwing listener can never crash the pipeline (AC-5). The
|
|
14
|
+
* override returns `Promise<void>` so callers in critical paths can `await`
|
|
15
|
+
* the wrapper; fire-and-forget callers (e.g. `progress` ticks) just drop
|
|
16
|
+
* the returned promise.
|
|
17
|
+
* - The orchestrator coexists with the existing `onProgress` callback rather
|
|
18
|
+
* than replacing it. `onProgress` remains the synchronous TUI render hook;
|
|
19
|
+
* events are the async multi-subscriber surface. Consolidation is an
|
|
20
|
+
* intentional non-goal (see issue #504, descope comment 2026-04-09).
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
import { EventEmitter } from "node:events";
|
|
25
|
+
const defaultClock = () => new Date();
|
|
26
|
+
/**
|
|
27
|
+
* Typed wrapper around `node:events.EventEmitter`.
|
|
28
|
+
*
|
|
29
|
+
* Type parameter `E` is the event-name → payload map. `on`, `off`, and
|
|
30
|
+
* `emit` are all narrowed to that map; passing an unknown event name fails
|
|
31
|
+
* compilation (AC-2).
|
|
32
|
+
*/
|
|
33
|
+
export class WorkflowEventEmitter {
|
|
34
|
+
inner = new EventEmitter();
|
|
35
|
+
clock;
|
|
36
|
+
onListenerError;
|
|
37
|
+
constructor(opts) {
|
|
38
|
+
this.clock = opts?.clock ?? defaultClock;
|
|
39
|
+
this.onListenerError = opts?.onListenerError ?? (() => { });
|
|
40
|
+
// Effectively no listener cap — multiple consumers (TUI, MCP, log writer
|
|
41
|
+
// in the future) can subscribe to popular events like `progress`.
|
|
42
|
+
this.inner.setMaxListeners(0);
|
|
43
|
+
}
|
|
44
|
+
/** Register a listener for the given event. */
|
|
45
|
+
on(event, listener) {
|
|
46
|
+
this.inner.on(event, listener);
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
/** Remove a previously registered listener. */
|
|
50
|
+
off(event, listener) {
|
|
51
|
+
this.inner.off(event, listener);
|
|
52
|
+
return this;
|
|
53
|
+
}
|
|
54
|
+
/** Number of listeners attached to `event`. Useful in tests. */
|
|
55
|
+
listenerCount(event) {
|
|
56
|
+
return this.inner.listenerCount(event);
|
|
57
|
+
}
|
|
58
|
+
/** Drop all listeners. Call from `RunOrchestrator` teardown to avoid leaks. */
|
|
59
|
+
removeAllListeners() {
|
|
60
|
+
this.inner.removeAllListeners();
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Emit an event. Listeners are invoked through `Promise.allSettled` so a
|
|
65
|
+
* single misbehaving subscriber cannot crash the pipeline (AC-5).
|
|
66
|
+
*
|
|
67
|
+
* Returns `Promise<void>` so critical-path callers may `await`. Fire-and-
|
|
68
|
+
* forget callers (`progress`, etc.) ignore the returned promise — the
|
|
69
|
+
* `Promise.allSettled` swallowing handles unhandled rejection warnings.
|
|
70
|
+
*
|
|
71
|
+
* The `timestamp` field on the payload is populated automatically when
|
|
72
|
+
* absent so call sites stay terse.
|
|
73
|
+
*/
|
|
74
|
+
emit(event, payload) {
|
|
75
|
+
const finalPayload = {
|
|
76
|
+
...payload,
|
|
77
|
+
timestamp: payload.timestamp ?? this.clock().toISOString(),
|
|
78
|
+
};
|
|
79
|
+
const listeners = this.inner.listeners(event);
|
|
80
|
+
if (listeners.length === 0) {
|
|
81
|
+
return Promise.resolve();
|
|
82
|
+
}
|
|
83
|
+
// Wrap each listener in `Promise.resolve().then(() => listener(payload))`
|
|
84
|
+
// so synchronous throws are reified into rejections before
|
|
85
|
+
// `Promise.allSettled` runs. Without the resolve-then trampoline, a
|
|
86
|
+
// synchronous throw inside a non-async listener would propagate
|
|
87
|
+
// immediately and bypass `allSettled`.
|
|
88
|
+
const settled = Promise.allSettled(listeners.map((listener) => Promise.resolve().then(() => listener(finalPayload))));
|
|
89
|
+
return settled.then((results) => {
|
|
90
|
+
for (const r of results) {
|
|
91
|
+
if (r.status === "rejected") {
|
|
92
|
+
try {
|
|
93
|
+
this.onListenerError(event, r.reason);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
/* swallow — error handler must not propagate */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderer-aware notice routing (#647 AC-3).
|
|
3
|
+
*
|
|
4
|
+
* Lives in its own module so both `phase-executor` and `run-orchestrator`
|
|
5
|
+
* can import without creating a backwards dependency (orchestrator → executor).
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
import type { PhasePauseHandle } from "./types.js";
|
|
10
|
+
/**
|
|
11
|
+
* Print a line to stdout while the renderer is active without breaking
|
|
12
|
+
* log-update's cursor model.
|
|
13
|
+
*
|
|
14
|
+
* `log-update` tracks `previousLineCount` from its own writes only; any
|
|
15
|
+
* out-of-band write to the same pty advances the cursor without its
|
|
16
|
+
* knowledge, so the next `eraseLines(previousLineCount)` undershoots and
|
|
17
|
+
* strands the prior frame's top rows in scrollback as duplicate headers.
|
|
18
|
+
*
|
|
19
|
+
* Routing:
|
|
20
|
+
* - With a `PhasePauseHandle` (TTY run): route through `appendNotice`,
|
|
21
|
+
* which clears the live zone, writes through the renderer's own
|
|
22
|
+
* stdout channel, then redraws. log-update's bookkeeping stays
|
|
23
|
+
* correct because the clear+redraw goes through the same path as
|
|
24
|
+
* a normal event line.
|
|
25
|
+
* - Without a handle (quiet mode / non-TTY / orchestrator): fall back
|
|
26
|
+
* to `console.log` — there's no live zone to corrupt.
|
|
27
|
+
*
|
|
28
|
+
* Callers in `phase-executor.ts` / `run-orchestrator.ts` must use this
|
|
29
|
+
* helper instead of raw `console.log` — enforced by ESLint
|
|
30
|
+
* `no-restricted-syntax` rule in `eslint.config.js`.
|
|
31
|
+
*/
|
|
32
|
+
export declare function bracketedConsoleLog(spinner: PhasePauseHandle | undefined, message: string): void;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderer-aware notice routing (#647 AC-3).
|
|
3
|
+
*
|
|
4
|
+
* Lives in its own module so both `phase-executor` and `run-orchestrator`
|
|
5
|
+
* can import without creating a backwards dependency (orchestrator → executor).
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Print a line to stdout while the renderer is active without breaking
|
|
11
|
+
* log-update's cursor model.
|
|
12
|
+
*
|
|
13
|
+
* `log-update` tracks `previousLineCount` from its own writes only; any
|
|
14
|
+
* out-of-band write to the same pty advances the cursor without its
|
|
15
|
+
* knowledge, so the next `eraseLines(previousLineCount)` undershoots and
|
|
16
|
+
* strands the prior frame's top rows in scrollback as duplicate headers.
|
|
17
|
+
*
|
|
18
|
+
* Routing:
|
|
19
|
+
* - With a `PhasePauseHandle` (TTY run): route through `appendNotice`,
|
|
20
|
+
* which clears the live zone, writes through the renderer's own
|
|
21
|
+
* stdout channel, then redraws. log-update's bookkeeping stays
|
|
22
|
+
* correct because the clear+redraw goes through the same path as
|
|
23
|
+
* a normal event line.
|
|
24
|
+
* - Without a handle (quiet mode / non-TTY / orchestrator): fall back
|
|
25
|
+
* to `console.log` — there's no live zone to corrupt.
|
|
26
|
+
*
|
|
27
|
+
* Callers in `phase-executor.ts` / `run-orchestrator.ts` must use this
|
|
28
|
+
* helper instead of raw `console.log` — enforced by ESLint
|
|
29
|
+
* `no-restricted-syntax` rule in `eslint.config.js`.
|
|
30
|
+
*/
|
|
31
|
+
export function bracketedConsoleLog(spinner, message) {
|
|
32
|
+
if (spinner) {
|
|
33
|
+
spinner.appendNotice(message);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
console.log(message);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -8,18 +8,9 @@
|
|
|
8
8
|
* is agent-agnostic.
|
|
9
9
|
*/
|
|
10
10
|
import { ShutdownManager } from "../shutdown.js";
|
|
11
|
-
|
|
12
|
-
* Lifecycle hook for pausing the run renderer's live zone while verbose
|
|
13
|
-
* Claude streaming writes through stdout, then resuming after the agent
|
|
14
|
-
* call completes. Replaces the legacy `PhaseSpinner` argument (#618).
|
|
15
|
-
*/
|
|
16
|
-
export interface PhasePauseHandle {
|
|
17
|
-
pause(): void;
|
|
18
|
-
resume(): void;
|
|
19
|
-
}
|
|
20
|
-
import { Phase, ExecutionConfig, PhaseResult, QaVerdict } from "./types.js";
|
|
11
|
+
import { Phase, ExecutionConfig, PhaseResult, QaVerdict, PhasePauseHandle } from "./types.js";
|
|
21
12
|
import type { QaSummary } from "./run-log-schema.js";
|
|
22
|
-
import type { AgentPhaseResult } from "./drivers/index.js";
|
|
13
|
+
import type { AgentPhaseResult, ResumeHandle } from "./drivers/index.js";
|
|
23
14
|
/**
|
|
24
15
|
* Leading + trailing throttle. Fires the wrapped callback immediately on the
|
|
25
16
|
* first call, drops subsequent calls that arrive inside `intervalMs` but
|
|
@@ -40,16 +31,10 @@ export declare function createThrottledReporter(fn: (text: string) => void, inte
|
|
|
40
31
|
report(text: string): void;
|
|
41
32
|
cancel(): void;
|
|
42
33
|
};
|
|
43
|
-
/**
|
|
44
|
-
* Spec-specific retry configuration.
|
|
45
|
-
* Spec failures have a higher failure rate (~8.6%) than other phases due to
|
|
46
|
-
* transient GitHub API issues and rate limits. One extra retry with backoff
|
|
47
|
-
* recovers most of these without user intervention.
|
|
48
|
-
*/
|
|
49
34
|
/** @internal Exported for testing only */
|
|
50
|
-
export declare const SPEC_RETRY_BACKOFF_MS
|
|
35
|
+
export declare const SPEC_RETRY_BACKOFF_MS: number;
|
|
51
36
|
/** @internal Exported for testing only */
|
|
52
|
-
export declare const SPEC_EXTRA_RETRIES
|
|
37
|
+
export declare const SPEC_EXTRA_RETRIES: number;
|
|
53
38
|
export declare function parseQaVerdict(output: string): QaVerdict | null;
|
|
54
39
|
/**
|
|
55
40
|
* Parse condensed QA summary from QA phase output (#434).
|
|
@@ -124,6 +109,7 @@ export declare function hasExecChanges(cwd: string): boolean;
|
|
|
124
109
|
*/
|
|
125
110
|
export declare function mapAgentSuccessToPhaseResult(phase: Phase, agentResult: AgentPhaseResult, durationSeconds: number, cwd: string): PhaseResult & {
|
|
126
111
|
sessionId?: string;
|
|
112
|
+
resumeHandle?: ResumeHandle;
|
|
127
113
|
};
|
|
128
114
|
/**
|
|
129
115
|
* Get the prompt for a phase with the issue number substituted.
|
|
@@ -137,8 +123,9 @@ export declare function getPhasePrompt(phase: Phase, issueNumber: number, agent?
|
|
|
137
123
|
/**
|
|
138
124
|
* Execute a single phase for an issue using the configured AgentDriver.
|
|
139
125
|
*/
|
|
140
|
-
declare function executePhase(issueNumber: number, phase: Phase, config: ExecutionConfig,
|
|
126
|
+
declare function executePhase(issueNumber: number, phase: Phase, config: ExecutionConfig, resumeHandle?: ResumeHandle, worktreePath?: string, shutdownManager?: ShutdownManager, spinner?: PhasePauseHandle): Promise<PhaseResult & {
|
|
141
127
|
sessionId?: string;
|
|
128
|
+
resumeHandle?: ResumeHandle;
|
|
142
129
|
}>;
|
|
143
130
|
/**
|
|
144
131
|
* Execute a phase with automatic retry for cold-start failures and MCP fallback.
|
|
@@ -154,11 +141,12 @@ declare function executePhase(issueNumber: number, phase: Phase, config: Executi
|
|
|
154
141
|
/**
|
|
155
142
|
* @internal Exported for testing only
|
|
156
143
|
*/
|
|
157
|
-
export declare function executePhaseWithRetry(issueNumber: number, phase: Phase, config: ExecutionConfig,
|
|
144
|
+
export declare function executePhaseWithRetry(issueNumber: number, phase: Phase, config: ExecutionConfig, resumeHandle?: ResumeHandle, worktreePath?: string, shutdownManager?: ShutdownManager, spinner?: PhasePauseHandle,
|
|
158
145
|
/** @internal Injected for testing — defaults to module-level executePhase */
|
|
159
146
|
executePhaseFn?: typeof executePhase,
|
|
160
147
|
/** @internal Injected for testing — defaults to setTimeout-based delay */
|
|
161
148
|
delayFn?: (ms: number) => Promise<void>): Promise<PhaseResult & {
|
|
162
149
|
sessionId?: string;
|
|
150
|
+
resumeHandle?: ResumeHandle;
|
|
163
151
|
}>;
|
|
164
152
|
export {};
|