iriai-build 0.1.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/bin/iriai-build.js +78 -0
- package/bridge-v3.js +98 -0
- package/cli/bootstrap.js +83 -0
- package/cli/commands/implementation.js +64 -0
- package/cli/commands/index.js +46 -0
- package/cli/commands/launch.js +153 -0
- package/cli/commands/plan.js +117 -0
- package/cli/commands/setup.js +80 -0
- package/cli/commands/slack.js +97 -0
- package/cli/commands/transfer.js +111 -0
- package/cli/config.js +92 -0
- package/cli/display.js +121 -0
- package/cli/terminal-input.js +666 -0
- package/cli/wait.js +82 -0
- package/index.js +1488 -0
- package/lib/agent-process.js +170 -0
- package/lib/bridge-state.js +126 -0
- package/lib/constants.js +137 -0
- package/lib/health-monitor.js +113 -0
- package/lib/prompt-builder.js +565 -0
- package/lib/signal-watcher.js +215 -0
- package/lib/slack-helpers.js +224 -0
- package/lib/state-machines/feature-lead.js +408 -0
- package/lib/state-machines/operator-agent.js +173 -0
- package/lib/state-machines/planning-role.js +161 -0
- package/lib/state-machines/role-agent.js +186 -0
- package/lib/state-machines/team-orchestrator.js +160 -0
- package/package.json +31 -0
- package/v3/.handover-html-evidence.md +35 -0
- package/v3/KICKOFF-HTML-EVIDENCE.md +98 -0
- package/v3/PLAN-HTML-EVIDENCE-HARDENING.md +603 -0
- package/v3/adapters/desktop-adapter.js +78 -0
- package/v3/adapters/interface.js +146 -0
- package/v3/adapters/slack-adapter.js +608 -0
- package/v3/adapters/slack-helpers.js +179 -0
- package/v3/adapters/terminal-adapter.js +249 -0
- package/v3/agent-supervisor.js +320 -0
- package/v3/artifact-portal.js +1184 -0
- package/v3/bridge.db +0 -0
- package/v3/constants.js +170 -0
- package/v3/db.js +76 -0
- package/v3/file-io.js +216 -0
- package/v3/helpers.js +174 -0
- package/v3/operator.js +364 -0
- package/v3/orchestrator.js +2886 -0
- package/v3/plan-compiler.js +440 -0
- package/v3/prompt-builder.js +849 -0
- package/v3/queries.js +461 -0
- package/v3/recovery.js +508 -0
- package/v3/review-sessions.js +360 -0
- package/v3/roles/accessibility-auditor/CLAUDE.md +50 -0
- package/v3/roles/analytics-engineer/CLAUDE.md +40 -0
- package/v3/roles/architect/CLAUDE.md +809 -0
- package/v3/roles/backend-implementer/CLAUDE.md +97 -0
- package/v3/roles/code-reviewer/CLAUDE.md +89 -0
- package/v3/roles/database-implementer/CLAUDE.md +97 -0
- package/v3/roles/deployer/CLAUDE.md +42 -0
- package/v3/roles/designer/CLAUDE.md +386 -0
- package/v3/roles/documentation/CLAUDE.md +40 -0
- package/v3/roles/feature-lead/CLAUDE.md +233 -0
- package/v3/roles/frontend-implementer/CLAUDE.md +97 -0
- package/v3/roles/implementer/CLAUDE.md +97 -0
- package/v3/roles/integration-tester/CLAUDE.md +174 -0
- package/v3/roles/observability-engineer/CLAUDE.md +40 -0
- package/v3/roles/operator/CLAUDE.md +322 -0
- package/v3/roles/orchestrator/CLAUDE.md +288 -0
- package/v3/roles/package-implementer/CLAUDE.md +47 -0
- package/v3/roles/performance-analyst/CLAUDE.md +49 -0
- package/v3/roles/plan-compiler/CLAUDE.md +163 -0
- package/v3/roles/planning-lead/CLAUDE.md +41 -0
- package/v3/roles/pm/CLAUDE.md +806 -0
- package/v3/roles/regression-tester/CLAUDE.md +135 -0
- package/v3/roles/release-manager/CLAUDE.md +43 -0
- package/v3/roles/security-auditor/CLAUDE.md +90 -0
- package/v3/roles/smoke-tester/CLAUDE.md +97 -0
- package/v3/roles/test-author/CLAUDE.md +42 -0
- package/v3/roles/verifier/CLAUDE.md +90 -0
- package/v3/schema.sql +134 -0
- package/v3/slack-adapter.js +510 -0
- package/v3/slack-helpers.js +346 -0
|
@@ -0,0 +1,2886 @@
|
|
|
1
|
+
// orchestrator.js — Feature lifecycle + agent dispatch state machines.
|
|
2
|
+
// The core brain of bridge v3. Replaces bridge-v2.js state machines + signal handlers.
|
|
3
|
+
// State lives in SQLite — no in-memory state machines.
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { execSync, spawn as cpSpawn } from "node:child_process";
|
|
8
|
+
import * as db from "./db.js";
|
|
9
|
+
import * as queries from "./queries.js";
|
|
10
|
+
import { AgentSupervisor } from "./agent-supervisor.js";
|
|
11
|
+
import AgentProcess from "../lib/agent-process.js";
|
|
12
|
+
import { FileIO } from "./file-io.js";
|
|
13
|
+
import { invokeOperator, invokeOperatorRelay, parseOperatorResponse } from "./operator.js";
|
|
14
|
+
import {
|
|
15
|
+
buildPlanningRolePrompt, buildArtifactSummarizerPrompt, buildRolePrompt, buildOrchestratorPrompt,
|
|
16
|
+
buildFeatureLeadInitPrompt, buildFeatureLeadRefreshPrompt, buildFeatureLeadTriggerPrompt,
|
|
17
|
+
} from "./prompt-builder.js";
|
|
18
|
+
import {
|
|
19
|
+
IMPL_BASE, IRIAI_TEAM_DIR, SCRIPTS_DIR,
|
|
20
|
+
V3_ROLES_DIR, PLANNING_ROLES, PLANNING_ROLE_LABELS,
|
|
21
|
+
ROLE_LABELS, PIPELINE_ORDER, SIGNAL,
|
|
22
|
+
STALE_SCAN_INTERVAL_MS, OPERATOR_RELAY_TIMEOUT_MS,
|
|
23
|
+
MAX_PLANNING_RETRIES, MAX_FL_RETRIES, MAX_FL_INIT_RETRIES,
|
|
24
|
+
MAX_ROLE_RETRIES, MAX_ORCH_RETRIES, MAX_OPERATOR_RETRIES,
|
|
25
|
+
FL_CONTEXT_EXHAUST_MS, FEATURE_REVIEW_ROLES,
|
|
26
|
+
ARTIFACT_SUMMARY_THRESHOLD_KB, ARTIFACT_SUMMARY_FILE,
|
|
27
|
+
SUMMARIZER_TIMEOUT_MS, SUMMARIZER_MODEL,
|
|
28
|
+
} from "./constants.js";
|
|
29
|
+
import {
|
|
30
|
+
readSignal, writeSignal, ensureDir, detectReposFromPlan, scaffoldNewRepo, slugify,
|
|
31
|
+
findArtifact,
|
|
32
|
+
} from "./helpers.js";
|
|
33
|
+
import { formatAnnotationsAsFeedback } from "./review-sessions.js";
|
|
34
|
+
import { compilePlanReviewHtml } from "./plan-compiler.js";
|
|
35
|
+
|
|
36
|
+
export class Orchestrator {
|
|
37
|
+
constructor({ adapter, reviewSessions = null }) {
|
|
38
|
+
this.adapter = adapter;
|
|
39
|
+
this.reviewSessions = reviewSessions;
|
|
40
|
+
this.supervisor = new AgentSupervisor();
|
|
41
|
+
this.fileIO = new FileIO();
|
|
42
|
+
|
|
43
|
+
// Per-feature signal trees (discovered on launch)
|
|
44
|
+
this._signalTrees = {};
|
|
45
|
+
|
|
46
|
+
// Operator relay queue processing state (per feature)
|
|
47
|
+
this._relayProcessing = {};
|
|
48
|
+
// Pending relay promises: relayQueueId → { resolve, timer }
|
|
49
|
+
this._relayWaiters = {};
|
|
50
|
+
// Deferred decisions: featureId → { decision, featureId } — posted after Operator relay completes
|
|
51
|
+
this._deferredDecisions = {};
|
|
52
|
+
|
|
53
|
+
this._staleScanTimer = null;
|
|
54
|
+
|
|
55
|
+
// When true, handlePlanApproval will NOT auto-launch implementation.
|
|
56
|
+
// The CLI sets this so it can prompt "Continue to implementation?" first.
|
|
57
|
+
this.deferImplLaunch = false;
|
|
58
|
+
|
|
59
|
+
this._setupFileIOHandlers();
|
|
60
|
+
this._setupSupervisorHandlers();
|
|
61
|
+
this.supervisor.startHealthMonitor();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
65
|
+
// FEATURE INITIALIZATION (at [FEATURE] detection)
|
|
66
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Initialize a feature from [FEATURE] detection: create per-feature dirs,
|
|
70
|
+
* feature channel, operator record, and start planning pipeline.
|
|
71
|
+
*/
|
|
72
|
+
async initializeFeature(slug, messageTs, userId) {
|
|
73
|
+
const featureDir = path.join(IMPL_BASE, "features", slug);
|
|
74
|
+
|
|
75
|
+
// 1. Create directories
|
|
76
|
+
const operatorDir = path.join(featureDir, "operator");
|
|
77
|
+
const plansDir = path.join(featureDir, "plans");
|
|
78
|
+
ensureDir(operatorDir);
|
|
79
|
+
ensureDir(plansDir);
|
|
80
|
+
|
|
81
|
+
const planningTree = {};
|
|
82
|
+
for (const role of PLANNING_ROLES) {
|
|
83
|
+
const roleDir = path.join(featureDir, "planning", role);
|
|
84
|
+
ensureDir(roleDir);
|
|
85
|
+
planningTree[role] = roleDir;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 2. Symlink CLAUDE.md from v3/roles/ into planning + operator dirs
|
|
89
|
+
for (const role of PLANNING_ROLES) {
|
|
90
|
+
const src = path.join(V3_ROLES_DIR, role, "CLAUDE.md");
|
|
91
|
+
const dest = path.join(planningTree[role], "CLAUDE.md");
|
|
92
|
+
try { fs.unlinkSync(dest); } catch { /* ok */ }
|
|
93
|
+
fs.symlinkSync(src, dest);
|
|
94
|
+
}
|
|
95
|
+
const opSrc = path.join(V3_ROLES_DIR, "operator", "CLAUDE.md");
|
|
96
|
+
const opDest = path.join(operatorDir, "CLAUDE.md");
|
|
97
|
+
try { fs.unlinkSync(opDest); } catch { /* ok */ }
|
|
98
|
+
fs.symlinkSync(opSrc, opDest);
|
|
99
|
+
|
|
100
|
+
// 3. Create feature in SQLite
|
|
101
|
+
const feature = queries.createFeature({ slug, threadTs: messageTs, signalDir: featureDir });
|
|
102
|
+
queries.insertEvent(feature.id, "system", `user:${userId}`, `Feature requested: ${slug}`);
|
|
103
|
+
|
|
104
|
+
// 4. Create feature channel immediately
|
|
105
|
+
const channelId = await this.adapter.createFeatureChannel(feature.id, slug);
|
|
106
|
+
if (channelId) {
|
|
107
|
+
queries.updateFeatureChannel(feature.id, channelId);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 5. Create Operator agent record (idle, ready for relay)
|
|
111
|
+
queries.createAgent({
|
|
112
|
+
featureId: feature.id,
|
|
113
|
+
agentType: "operator",
|
|
114
|
+
agentKey: `op-${slug}`,
|
|
115
|
+
roleName: "operator",
|
|
116
|
+
signalDir: operatorDir,
|
|
117
|
+
cwd: featureDir,
|
|
118
|
+
maxRetries: MAX_OPERATOR_RETRIES,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// 6. Post link in #planning thread
|
|
122
|
+
if (channelId) {
|
|
123
|
+
await this.adapter.postThreadMessage(feature.id,
|
|
124
|
+
`*[Pipeline]* Starting planning for *${slug}*. Implementation channel: <#${channelId}>`);
|
|
125
|
+
await this.adapter.postMessage(feature.id,
|
|
126
|
+
`*[Pipeline]* Planning pipeline started for feature: *${slug}*\nPhase 1: Product Manager interview`);
|
|
127
|
+
} else {
|
|
128
|
+
await this.adapter.postThreadMessage(feature.id,
|
|
129
|
+
`*[Pipeline]* Starting planning pipeline for: *${slug}*\nPhase 1: Product Manager interview`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 7. Cache signal tree with planning field
|
|
133
|
+
this._signalTrees[slug] = {
|
|
134
|
+
featureDir,
|
|
135
|
+
featureLead: null,
|
|
136
|
+
operator: operatorDir,
|
|
137
|
+
featureReview: {},
|
|
138
|
+
teams: {},
|
|
139
|
+
planning: planningTree,
|
|
140
|
+
plansDir,
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// 8. Start watching per-feature planning + operator signals
|
|
144
|
+
this.fileIO.watchFeaturePlanningSignals(slug, planningTree, operatorDir);
|
|
145
|
+
|
|
146
|
+
// 9. Set active planning role and dispatch PM
|
|
147
|
+
queries.updateFeaturePlanningRole(feature.id, "pm");
|
|
148
|
+
this.dispatchPlanningRole(feature.id, "pm");
|
|
149
|
+
|
|
150
|
+
return feature;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
154
|
+
// PLANNING PIPELINE
|
|
155
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Dispatch a planning role agent (PM, Designer, Architect, Plan-Compiler).
|
|
159
|
+
*/
|
|
160
|
+
dispatchPlanningRole(featureId, role, opts = {}) {
|
|
161
|
+
const feature = queries.getFeatureById(featureId);
|
|
162
|
+
if (!feature) return;
|
|
163
|
+
|
|
164
|
+
// Compute dirs from feature record instead of global ROLE_DIRS
|
|
165
|
+
const featureDir = feature.signal_dir;
|
|
166
|
+
const signalDir = path.join(featureDir, "planning", role);
|
|
167
|
+
const planDir = path.join(featureDir, "plans");
|
|
168
|
+
ensureDir(signalDir);
|
|
169
|
+
ensureDir(planDir);
|
|
170
|
+
|
|
171
|
+
// Use repos worktree dir as cwd if it exists, otherwise signal dir
|
|
172
|
+
const reposDir = path.join(process.env.HOME, "src/iriai/.features", feature.slug, "repos");
|
|
173
|
+
const roleCwd = fs.existsSync(reposDir) ? reposDir : signalDir;
|
|
174
|
+
|
|
175
|
+
const agentKey = `${role}-${feature.slug}`;
|
|
176
|
+
|
|
177
|
+
// Kill existing if any
|
|
178
|
+
this.supervisor.kill(agentKey);
|
|
179
|
+
|
|
180
|
+
// Clean up stale signal files from previous run so the file watcher
|
|
181
|
+
// doesn't immediately fire _handlePlanningDone before the agent starts.
|
|
182
|
+
for (const sig of [SIGNAL.DONE, SIGNAL.OUTPUT]) {
|
|
183
|
+
try { fs.unlinkSync(path.join(signalDir, sig)); } catch { /* ok */ }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Create or update agent record
|
|
187
|
+
let agent = queries.getAgentByKey(agentKey);
|
|
188
|
+
const hasSession = agent?.started_at != null;
|
|
189
|
+
if (!agent) {
|
|
190
|
+
agent = queries.createAgent({
|
|
191
|
+
featureId,
|
|
192
|
+
agentType: "planning-role",
|
|
193
|
+
agentKey,
|
|
194
|
+
roleName: role,
|
|
195
|
+
signalDir,
|
|
196
|
+
cwd: roleCwd,
|
|
197
|
+
maxRetries: MAX_PLANNING_RETRIES,
|
|
198
|
+
});
|
|
199
|
+
} else {
|
|
200
|
+
queries.updateAgentStatus(agent.id, "idle");
|
|
201
|
+
queries.resetAgentRetry(agent.id);
|
|
202
|
+
this._syncAgentRetries(agent, MAX_PLANNING_RETRIES);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Build task from any existing .task file in the signal dir
|
|
206
|
+
let task = "";
|
|
207
|
+
const taskFile = path.join(signalDir, SIGNAL.TASK);
|
|
208
|
+
if (fs.existsSync(taskFile)) {
|
|
209
|
+
task = fs.readFileSync(taskFile, "utf-8").trim();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Check for pending .user-message (e.g. revision feedback) and inline it
|
|
213
|
+
const userMsgFile = path.join(signalDir, SIGNAL.USER_MESSAGE);
|
|
214
|
+
let revisionContext = "";
|
|
215
|
+
if (fs.existsSync(userMsgFile)) {
|
|
216
|
+
const msg = fs.readFileSync(userMsgFile, "utf-8").trim();
|
|
217
|
+
if (msg) {
|
|
218
|
+
revisionContext = `\n\n--- REVISION FEEDBACK ---\n${msg}\n--- END FEEDBACK ---\n\nYou are being re-dispatched after a phase review rejection. Address the feedback above. Read your previous output in $PLAN_DIR/ and revise it.`;
|
|
219
|
+
}
|
|
220
|
+
// Don't delete — the agent's poll loop will also pick it up as a belt-and-suspenders
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Add feature header with PLAN_DIR, FEATURE_DIR, REPOS_DIR
|
|
224
|
+
const taskHeader = [
|
|
225
|
+
`SLACK_MODE=true`,
|
|
226
|
+
`FEATURE_SLUG=${feature.slug}`,
|
|
227
|
+
`SIGNAL_DIR=${signalDir}`,
|
|
228
|
+
`PLAN_DIR=${planDir}`,
|
|
229
|
+
`FEATURE_DIR=${featureDir}`,
|
|
230
|
+
`REPOS_DIR=${reposDir}`,
|
|
231
|
+
`THREAD_TS=${feature.thread_ts}`,
|
|
232
|
+
].join("\n");
|
|
233
|
+
const fullTask = task ? `${taskHeader}\n\n${task}${revisionContext}` : `${taskHeader}${revisionContext}`;
|
|
234
|
+
|
|
235
|
+
const prompt = buildPlanningRolePrompt({ task: fullTask, signalDir, featureSlug: feature.slug });
|
|
236
|
+
|
|
237
|
+
queries.updateFeaturePlanningRole(featureId, role);
|
|
238
|
+
queries.insertEvent(featureId, "agent-started", "bridge", `Planning role ${role} dispatched`);
|
|
239
|
+
|
|
240
|
+
// Use --continue if agent has a prior session and caller didn't explicitly set continue
|
|
241
|
+
const useContinue = opts.continue !== undefined ? opts.continue : hasSession;
|
|
242
|
+
this.supervisor.spawn(agent.id, prompt, { continue: useContinue });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Handle planning role completion — upload artifact + post review gate (or handle plan-compiler).
|
|
247
|
+
*/
|
|
248
|
+
async _handlePlanningDone(slug, role, filePath) {
|
|
249
|
+
const dir = path.dirname(filePath);
|
|
250
|
+
const label = ROLE_LABELS[role] || role;
|
|
251
|
+
|
|
252
|
+
const feature = queries.getFeatureBySlug(slug);
|
|
253
|
+
if (!feature) return;
|
|
254
|
+
|
|
255
|
+
// Dedup guard: both file watcher (planning:done) and agent exit handler
|
|
256
|
+
// (_handlePlanningRoleExit) can call this for the same .done file.
|
|
257
|
+
// We use a synchronous in-memory lock + metadata check to prevent concurrent processing.
|
|
258
|
+
const dedupKey = `planningDone:${feature.id}:${role}`;
|
|
259
|
+
if (this._planningDoneLocks?.[dedupKey]) return;
|
|
260
|
+
const meta = queries.getFeatureMetadata(feature.id);
|
|
261
|
+
if (meta.awaiting_phase_review && meta.phase_review_role === role) return;
|
|
262
|
+
if (role === "plan-compiler" && feature.phase === "plan-approval") return;
|
|
263
|
+
|
|
264
|
+
// Acquire lock before any async work
|
|
265
|
+
if (!this._planningDoneLocks) this._planningDoneLocks = {};
|
|
266
|
+
this._planningDoneLocks[dedupKey] = true;
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
// Read .output
|
|
270
|
+
const outputPath = path.join(dir, SIGNAL.OUTPUT);
|
|
271
|
+
let output = "";
|
|
272
|
+
if (fs.existsSync(outputPath)) {
|
|
273
|
+
output = fs.readFileSync(outputPath, "utf-8").trim();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
queries.insertEvent(feature.id, "phase-transition", "bridge", `${label} complete`, { output });
|
|
277
|
+
|
|
278
|
+
// Clean up .done/.output after reading — output is already in memory.
|
|
279
|
+
try { fs.unlinkSync(filePath); } catch { /* ok */ }
|
|
280
|
+
if (fs.existsSync(outputPath)) try { fs.unlinkSync(outputPath); } catch { /* ok */ }
|
|
281
|
+
|
|
282
|
+
if (role === "plan-compiler") {
|
|
283
|
+
// Check PASS/FAIL
|
|
284
|
+
const isPassing = output && (output.startsWith("PASS") || output.startsWith("PASS_WITH_WARNINGS"));
|
|
285
|
+
if (!isPassing) {
|
|
286
|
+
// FAIL → re-dispatch architect (no user gate)
|
|
287
|
+
await this.adapter.postPipelineMessage(feature.id,
|
|
288
|
+
`Plan validation failed. Re-dispatching Architect.\n\n${output}`);
|
|
289
|
+
queries.updateFeaturePlanningRole(feature.id, "architect");
|
|
290
|
+
const archDir = path.join(feature.signal_dir, "planning", "architect");
|
|
291
|
+
ensureDir(archDir);
|
|
292
|
+
writeSignal(path.join(archDir, SIGNAL.USER_MESSAGE),
|
|
293
|
+
`REVISION REQUESTED: Plan compiler validation failed.\n\n${output}`);
|
|
294
|
+
this.dispatchPlanningRole(feature.id, "architect");
|
|
295
|
+
} else {
|
|
296
|
+
// PASS → final plan approval (uploads + buttons)
|
|
297
|
+
db.transaction(() => {
|
|
298
|
+
queries.updateFeaturePhase(feature.id, "plan-approval");
|
|
299
|
+
queries.updateFeaturePlanningRole(feature.id, null);
|
|
300
|
+
});
|
|
301
|
+
const planDir = path.join(feature.signal_dir, "plans");
|
|
302
|
+
|
|
303
|
+
// Upload artifacts (without decision buttons — those are deferred)
|
|
304
|
+
for (const art of [
|
|
305
|
+
{ name: "PRD", pattern: "prd" },
|
|
306
|
+
{ name: "Design Decisions", pattern: "design-decisions" },
|
|
307
|
+
{ name: "Implementation Plan", pattern: "implementation-plan" },
|
|
308
|
+
]) {
|
|
309
|
+
const artPath = findArtifact(art.pattern, planDir);
|
|
310
|
+
if (artPath) await this.adapter.uploadArtifact(feature.id, artPath, art.name);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Start interactive plan review session (compiled tabbed HTML)
|
|
314
|
+
let reviewUrl = null;
|
|
315
|
+
if (this.reviewSessions) {
|
|
316
|
+
const compiledPath = compilePlanReviewHtml(planDir);
|
|
317
|
+
if (compiledPath) {
|
|
318
|
+
reviewUrl = await this.reviewSessions.startDocReview(
|
|
319
|
+
"plan-approval", compiledPath, "Plan Review", { featureId: feature.id }
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const planDecision = {
|
|
325
|
+
id: "plan-approval",
|
|
326
|
+
title: "Plan ready for approval",
|
|
327
|
+
context: "All planning phases complete. Review the artifacts.",
|
|
328
|
+
options: [
|
|
329
|
+
{ id: "approve", label: "Approve Plan", style: "primary" },
|
|
330
|
+
{ id: "reject", label: "Reject Plan", style: "danger" },
|
|
331
|
+
],
|
|
332
|
+
reviewUrl,
|
|
333
|
+
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// Defer decision until after Operator relay completes
|
|
337
|
+
this._deferredDecisions[feature.id] = { decision: planDecision, featureId: feature.id };
|
|
338
|
+
this._notifyOperatorOfDecision(feature, planDecision);
|
|
339
|
+
}
|
|
340
|
+
} else {
|
|
341
|
+
// Non-final role → summary + artifact upload + Block Kit review gate (all sequential)
|
|
342
|
+
await this._requestPhaseReview(feature, role, output);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
} finally {
|
|
346
|
+
// Release the dedup lock
|
|
347
|
+
delete this._planningDoneLocks[dedupKey];
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Post phase summary, upload artifact, then post Block Kit approve/reject buttons.
|
|
353
|
+
* Everything is sequential to guarantee correct message ordering.
|
|
354
|
+
*/
|
|
355
|
+
async _requestPhaseReview(feature, role, output) {
|
|
356
|
+
const planDir = path.join(feature.signal_dir, "plans");
|
|
357
|
+
const label = PLANNING_ROLE_LABELS[role] || role;
|
|
358
|
+
|
|
359
|
+
// Artifact definitions — used for review session targets and fallback uploads
|
|
360
|
+
const ARTIFACT_MAP = {
|
|
361
|
+
pm: [{ name: "PRD", pattern: "prd" }],
|
|
362
|
+
designer: [
|
|
363
|
+
{ name: "Design Decisions", pattern: "design-decisions" },
|
|
364
|
+
{ name: "UI Mockup", file: "mockup.html" },
|
|
365
|
+
],
|
|
366
|
+
architect: [
|
|
367
|
+
{ name: "Implementation Plan (plan.yaml)", file: "plan.yaml" },
|
|
368
|
+
{ name: "Architecture Context", pattern: "context" },
|
|
369
|
+
],
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const artifacts = ARTIFACT_MAP[role] || [];
|
|
373
|
+
|
|
374
|
+
// Only upload raw artifacts if review sessions are NOT available
|
|
375
|
+
// (review sessions replace file uploads with interactive doc review URLs)
|
|
376
|
+
if (!this.reviewSessions) {
|
|
377
|
+
for (const artifact of artifacts) {
|
|
378
|
+
const artifactPath = artifact.file
|
|
379
|
+
? (fs.existsSync(path.join(planDir, artifact.file)) ? path.join(planDir, artifact.file) : null)
|
|
380
|
+
: findArtifact(artifact.pattern, planDir);
|
|
381
|
+
if (artifactPath) {
|
|
382
|
+
await this.adapter.uploadArtifact(feature.id, artifactPath, artifact.name);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const artifact = artifacts[0]; // primary artifact for button text
|
|
387
|
+
|
|
388
|
+
const nextRole = PIPELINE_ORDER[PIPELINE_ORDER.indexOf(role) + 1];
|
|
389
|
+
const nextLabel = PLANNING_ROLE_LABELS[nextRole] || nextRole;
|
|
390
|
+
const decisionId = `phase-review-${feature.slug}-${role}`;
|
|
391
|
+
|
|
392
|
+
// Set awaiting_phase_review BEFORE postDecision so handleDecisionClick
|
|
393
|
+
// can find the feature when the user responds.
|
|
394
|
+
const meta = queries.getFeatureMetadata(feature.id);
|
|
395
|
+
meta.awaiting_phase_review = true;
|
|
396
|
+
meta.phase_review_role = role;
|
|
397
|
+
meta.phase_review_ts = decisionId;
|
|
398
|
+
queries.updateFeatureMetadata(feature.id, meta);
|
|
399
|
+
|
|
400
|
+
// For designer phase: detect mockup.html, serve it, inject URL into design-decisions.md
|
|
401
|
+
if (role === "designer" && this.reviewSessions) {
|
|
402
|
+
await this._ensureMockupSession(planDir, `${decisionId}-mockup`, feature.id);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Start interactive review session for the primary artifact
|
|
406
|
+
let reviewUrl = null;
|
|
407
|
+
if (this.reviewSessions) {
|
|
408
|
+
const primaryArtifact = artifacts[0];
|
|
409
|
+
const primaryPath = primaryArtifact?.file
|
|
410
|
+
? (fs.existsSync(path.join(planDir, primaryArtifact.file)) ? path.join(planDir, primaryArtifact.file) : null)
|
|
411
|
+
: findArtifact(primaryArtifact?.pattern, planDir);
|
|
412
|
+
if (primaryPath) {
|
|
413
|
+
reviewUrl = await this.reviewSessions.startDocReview(
|
|
414
|
+
decisionId, primaryPath, primaryArtifact.name, { featureId: feature.id }
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const decision = {
|
|
420
|
+
id: decisionId,
|
|
421
|
+
title: `${label} phase complete — review the ${artifact?.name || "output"} above`,
|
|
422
|
+
context: `Approve to proceed to ${nextLabel}, or reject to request revisions.`,
|
|
423
|
+
options: [
|
|
424
|
+
{ id: "approve", label: "Approve", style: "primary" },
|
|
425
|
+
{ id: "reject", label: "Request Revisions", style: "danger" },
|
|
426
|
+
],
|
|
427
|
+
reviewUrl,
|
|
428
|
+
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// Defer the decision post until after the Operator relay completes,
|
|
432
|
+
// so the Operator's "PRD complete" message appears first.
|
|
433
|
+
this._deferredDecisions[feature.id] = { decision, featureId: feature.id };
|
|
434
|
+
|
|
435
|
+
// Notify the Operator — when the relay finishes, _postDeferredDecision() fires.
|
|
436
|
+
this._notifyOperatorOfDecision(feature, decision, output);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Notify the Operator about a pipeline decision via relay.
|
|
441
|
+
* Gives the Operator awareness and context regardless of adapter mode.
|
|
442
|
+
*/
|
|
443
|
+
_notifyOperatorOfDecision(feature, decision, agentOutput) {
|
|
444
|
+
const optionsList = decision.options
|
|
445
|
+
.map(o => `${o.label} (id: ${o.id})`)
|
|
446
|
+
.join(", ");
|
|
447
|
+
|
|
448
|
+
const summary = agentOutput
|
|
449
|
+
? `Agent output summary: ${agentOutput.slice(0, 500)}`
|
|
450
|
+
: "";
|
|
451
|
+
|
|
452
|
+
const reviewLine = decision.reviewUrl
|
|
453
|
+
? `Review URL: ${decision.reviewUrl} (user can annotate feedback in browser)`
|
|
454
|
+
: "";
|
|
455
|
+
const qaLine = decision.qaUrl
|
|
456
|
+
? `QA Test URL: ${decision.qaUrl} (user can test the live app)`
|
|
457
|
+
: "";
|
|
458
|
+
|
|
459
|
+
const content = [
|
|
460
|
+
`Pipeline event: ${decision.title}`,
|
|
461
|
+
decision.context || "",
|
|
462
|
+
`Decision ID: ${decision.id}`,
|
|
463
|
+
`Options: ${optionsList}`,
|
|
464
|
+
reviewLine,
|
|
465
|
+
qaLine,
|
|
466
|
+
summary,
|
|
467
|
+
].filter(Boolean).join("\n");
|
|
468
|
+
|
|
469
|
+
this._enqueueForOperatorRelay(feature, "Pipeline", "decision-needed", content);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Re-post a pending phase review decision on resume.
|
|
474
|
+
* Called when a feature is in awaiting_phase_review state but the decision
|
|
475
|
+
* prompt was lost (e.g. terminal session died).
|
|
476
|
+
*/
|
|
477
|
+
async repostPendingPhaseReview(featureId) {
|
|
478
|
+
const feature = queries.getFeatureById(featureId);
|
|
479
|
+
if (!feature) return;
|
|
480
|
+
const meta = queries.getFeatureMetadata(featureId);
|
|
481
|
+
if (!meta.awaiting_phase_review || !meta.phase_review_role) return;
|
|
482
|
+
|
|
483
|
+
const role = meta.phase_review_role;
|
|
484
|
+
const label = PLANNING_ROLE_LABELS[role] || role;
|
|
485
|
+
const nextRole = PIPELINE_ORDER[PIPELINE_ORDER.indexOf(role) + 1];
|
|
486
|
+
const nextLabel = nextRole ? (PLANNING_ROLE_LABELS[nextRole] || nextRole) : "Plan Approval";
|
|
487
|
+
const decisionId = `phase-review-${feature.slug}-${role}`;
|
|
488
|
+
|
|
489
|
+
// Upload artifacts again so the user can review
|
|
490
|
+
const planDir = path.join(feature.signal_dir, "plans");
|
|
491
|
+
const ARTIFACT_MAP = {
|
|
492
|
+
pm: [{ name: "PRD", pattern: "prd" }],
|
|
493
|
+
designer: [
|
|
494
|
+
{ name: "Design Decisions", pattern: "design-decisions" },
|
|
495
|
+
{ name: "UI Mockup", file: "mockup.html" },
|
|
496
|
+
],
|
|
497
|
+
architect: [
|
|
498
|
+
{ name: "Implementation Plan (plan.yaml)", file: "plan.yaml" },
|
|
499
|
+
{ name: "Architecture Context", pattern: "context" },
|
|
500
|
+
],
|
|
501
|
+
};
|
|
502
|
+
const artifacts = ARTIFACT_MAP[role] || [];
|
|
503
|
+
|
|
504
|
+
// Restore or start mockup session for designer phase
|
|
505
|
+
if (role === "designer" && this.reviewSessions) {
|
|
506
|
+
await this._ensureMockupSession(planDir, `${decisionId}-mockup`, feature.id);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Restore or start review session for the primary artifact
|
|
510
|
+
let reviewUrl = null;
|
|
511
|
+
if (this.reviewSessions) {
|
|
512
|
+
reviewUrl = await this.reviewSessions.restoreSession(decisionId);
|
|
513
|
+
if (!reviewUrl) {
|
|
514
|
+
// No saved session — start a fresh one
|
|
515
|
+
const primaryArtifact = artifacts[0];
|
|
516
|
+
const primaryPath = primaryArtifact?.file
|
|
517
|
+
? (fs.existsSync(path.join(planDir, primaryArtifact.file)) ? path.join(planDir, primaryArtifact.file) : null)
|
|
518
|
+
: findArtifact(primaryArtifact?.pattern, planDir);
|
|
519
|
+
if (primaryPath) {
|
|
520
|
+
reviewUrl = await this.reviewSessions.startDocReview(
|
|
521
|
+
decisionId, primaryPath, primaryArtifact.name, { featureId: feature.id }
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Only upload raw artifacts if no review session (fallback for non-review mode)
|
|
528
|
+
if (!reviewUrl) {
|
|
529
|
+
for (const artifact of artifacts) {
|
|
530
|
+
const artifactPath = artifact.file
|
|
531
|
+
? (fs.existsSync(path.join(planDir, artifact.file)) ? path.join(planDir, artifact.file) : null)
|
|
532
|
+
: findArtifact(artifact.pattern, planDir);
|
|
533
|
+
if (artifactPath) {
|
|
534
|
+
await this.adapter.uploadArtifact(feature.id, artifactPath, artifact.name);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const decision = {
|
|
540
|
+
id: decisionId,
|
|
541
|
+
title: `${label} phase complete — review the ${artifacts[0]?.name || "output"} above`,
|
|
542
|
+
context: `Approve to proceed to ${nextLabel}, or reject to request revisions.`,
|
|
543
|
+
options: [
|
|
544
|
+
{ id: "approve", label: "Approve", style: "primary" },
|
|
545
|
+
{ id: "reject", label: "Request Revisions", style: "danger" },
|
|
546
|
+
],
|
|
547
|
+
reviewUrl,
|
|
548
|
+
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
// Post decision directly — no deferred/Operator relay for reposts
|
|
552
|
+
await this.adapter.postDecision(feature.id, decision);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Re-present any pending decision on resume (restart/reconnect).
|
|
557
|
+
* Looks up the pending decision from DB and re-notifies the Operator.
|
|
558
|
+
*/
|
|
559
|
+
async repostPendingDecision(featureId) {
|
|
560
|
+
const feature = queries.getFeatureById(featureId);
|
|
561
|
+
if (!feature) return;
|
|
562
|
+
|
|
563
|
+
// Check for awaiting_phase_review first — has its own richer repost logic
|
|
564
|
+
const meta = queries.getFeatureMetadata(featureId);
|
|
565
|
+
if (meta.awaiting_phase_review) {
|
|
566
|
+
return this.repostPendingPhaseReview(featureId);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Check for plan-approval phase
|
|
570
|
+
if (feature.phase === "plan-approval") {
|
|
571
|
+
// Restore or start plan review session
|
|
572
|
+
let reviewUrl = null;
|
|
573
|
+
if (this.reviewSessions) {
|
|
574
|
+
reviewUrl = await this.reviewSessions.restoreSession("plan-approval");
|
|
575
|
+
if (!reviewUrl) {
|
|
576
|
+
// No prior session — compile and start fresh
|
|
577
|
+
const planDir = path.join(feature.signal_dir, "plans");
|
|
578
|
+
const compiledPath = compilePlanReviewHtml(planDir);
|
|
579
|
+
if (compiledPath) {
|
|
580
|
+
reviewUrl = await this.reviewSessions.startDocReview(
|
|
581
|
+
"plan-approval", compiledPath, "Plan Review", { featureId }
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const decision = {
|
|
588
|
+
id: "plan-approval",
|
|
589
|
+
title: "Plan ready for approval",
|
|
590
|
+
context: "All planning phases complete. Review the artifacts.",
|
|
591
|
+
options: [
|
|
592
|
+
{ id: "approve", label: "Approve Plan", style: "primary" },
|
|
593
|
+
{ id: "reject", label: "Reject Plan", style: "danger" },
|
|
594
|
+
],
|
|
595
|
+
reviewUrl,
|
|
596
|
+
|
|
597
|
+
};
|
|
598
|
+
await this.adapter.postDecision(feature.id, decision);
|
|
599
|
+
this._notifyOperatorOfDecision(feature, decision);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Generic: re-notify for any pending decision in DB
|
|
604
|
+
const pendingDecision = queries.getPendingDecision(featureId);
|
|
605
|
+
if (pendingDecision) {
|
|
606
|
+
const options = JSON.parse(pendingDecision.options || "[]");
|
|
607
|
+
const decision = {
|
|
608
|
+
id: pendingDecision.decision_id,
|
|
609
|
+
title: pendingDecision.title || pendingDecision.decision_id,
|
|
610
|
+
context: pendingDecision.context_text || "",
|
|
611
|
+
options,
|
|
612
|
+
};
|
|
613
|
+
this._notifyOperatorOfDecision(feature, decision);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Handle phase review approval — advance to next planning role.
|
|
619
|
+
*/
|
|
620
|
+
async handlePhaseReviewApproval(featureId) {
|
|
621
|
+
const feature = queries.getFeatureById(featureId);
|
|
622
|
+
if (!feature) return;
|
|
623
|
+
|
|
624
|
+
const meta = queries.getFeatureMetadata(featureId);
|
|
625
|
+
const role = meta.phase_review_role;
|
|
626
|
+
if (!role) return;
|
|
627
|
+
|
|
628
|
+
const label = PLANNING_ROLE_LABELS[role] || role;
|
|
629
|
+
|
|
630
|
+
// Clear review state
|
|
631
|
+
meta.awaiting_phase_review = false;
|
|
632
|
+
meta.phase_review_role = null;
|
|
633
|
+
meta.phase_review_ts = null;
|
|
634
|
+
queries.updateFeatureMetadata(featureId, meta);
|
|
635
|
+
|
|
636
|
+
const currentIndex = PIPELINE_ORDER.indexOf(role);
|
|
637
|
+
if (currentIndex >= 0 && currentIndex < PIPELINE_ORDER.length - 1) {
|
|
638
|
+
const nextRole = PIPELINE_ORDER[currentIndex + 1];
|
|
639
|
+
const nextLabel = PLANNING_ROLE_LABELS[nextRole] || nextRole;
|
|
640
|
+
|
|
641
|
+
await this.adapter.postPipelineMessage(feature.id,
|
|
642
|
+
`${label} approved. Starting ${nextLabel}...`);
|
|
643
|
+
|
|
644
|
+
db.transaction(() => {
|
|
645
|
+
queries.updateFeaturePlanningRole(feature.id, nextRole);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
this.dispatchPlanningRole(feature.id, nextRole);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
queries.insertEvent(featureId, "phase-transition", "bridge", `${role} phase approved`);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Handle phase review rejection — re-dispatch same role with feedback.
|
|
656
|
+
*/
|
|
657
|
+
async handlePhaseReviewRejection(featureId, reason) {
|
|
658
|
+
const feature = queries.getFeatureById(featureId);
|
|
659
|
+
if (!feature) return;
|
|
660
|
+
|
|
661
|
+
const meta = queries.getFeatureMetadata(featureId);
|
|
662
|
+
const role = meta.phase_review_role;
|
|
663
|
+
if (!role) return;
|
|
664
|
+
|
|
665
|
+
const label = PLANNING_ROLE_LABELS[role] || role;
|
|
666
|
+
|
|
667
|
+
// Clear review state
|
|
668
|
+
meta.awaiting_phase_review = false;
|
|
669
|
+
meta.phase_review_role = null;
|
|
670
|
+
meta.phase_review_ts = null;
|
|
671
|
+
queries.updateFeatureMetadata(featureId, meta);
|
|
672
|
+
|
|
673
|
+
await this.adapter.postPipelineMessage(feature.id,
|
|
674
|
+
`Revision requested for ${label}: "${reason || "Please revise."}". Re-dispatching...`);
|
|
675
|
+
|
|
676
|
+
const roleDir = path.join(feature.signal_dir, "planning", role);
|
|
677
|
+
ensureDir(roleDir);
|
|
678
|
+
|
|
679
|
+
// Clean up stale .done/.output so file watcher doesn't immediately re-trigger
|
|
680
|
+
for (const sig of [SIGNAL.DONE, SIGNAL.OUTPUT]) {
|
|
681
|
+
try { fs.unlinkSync(path.join(roleDir, sig)); } catch { /* ok */ }
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Check if the agent has a prior session to continue
|
|
685
|
+
const agentKey = `${role}-${feature.slug}`;
|
|
686
|
+
const agent = queries.getAgentByKey(agentKey);
|
|
687
|
+
|
|
688
|
+
if (agent?.started_at != null) {
|
|
689
|
+
// Agent has prior session — use --continue with just the revision feedback
|
|
690
|
+
// No need to rebuild full prompt; the agent has full prior context
|
|
691
|
+
const revisionPrompt = `REVISION REQUESTED by the user:\n\n${reason || "Please revise your output."}\n\nIMPORTANT PROTOCOL:\n- If the feedback is unclear or you need clarification, write your question to .agent-response and then poll for .user-message (while [ ! -f .user-message ]; do sleep 5; done). Do NOT write .done until you have received the user's answer and completed your revision.\n- Do NOT write .done if you are asking a question. You must wait for the response first.\n- Only write .done after the revision is fully complete.`;
|
|
692
|
+
|
|
693
|
+
this.supervisor.kill(agentKey);
|
|
694
|
+
queries.updateAgentStatus(agent.id, "idle");
|
|
695
|
+
queries.resetAgentRetry(agent.id);
|
|
696
|
+
this.supervisor.spawn(agent.id, revisionPrompt, { continue: true });
|
|
697
|
+
} else {
|
|
698
|
+
// No prior session — fall back to full re-dispatch with revision context
|
|
699
|
+
writeSignal(path.join(roleDir, SIGNAL.USER_MESSAGE),
|
|
700
|
+
`REVISION REQUESTED: ${reason || "Please revise your output."}\n\nIMPORTANT PROTOCOL:\n- If the feedback is unclear or you need clarification, write your question to .agent-response and then poll for .user-message (while [ ! -f .user-message ]; do sleep 5; done). Do NOT write .done until you have received the user's answer and completed your revision.\n- Do NOT write .done if you are asking a question. You must wait for the response first.\n- Only write .done after the revision is fully complete.`);
|
|
701
|
+
this.dispatchPlanningRole(featureId, role, { continue: false });
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
queries.insertEvent(featureId, "phase-transition", "bridge", `${role} phase rejected: ${reason}`);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Handle plan approval — create branches and launch implementation directly.
|
|
709
|
+
*/
|
|
710
|
+
async handlePlanApproval(featureId) {
|
|
711
|
+
const feature = queries.getFeatureById(featureId);
|
|
712
|
+
if (!feature) return;
|
|
713
|
+
|
|
714
|
+
if (this.deferImplLaunch) {
|
|
715
|
+
// CLI mode: don't auto-launch impl. CLI will prompt "continue to impl?" and
|
|
716
|
+
// call startImplementation() separately.
|
|
717
|
+
queries.insertEvent(featureId, "phase-transition", "bridge", "Plan approved (impl deferred to CLI prompt)");
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Normal mode (Slack): create branches + launch implementation immediately
|
|
722
|
+
await this.startImplementation(featureId);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Start implementation for a feature: create branches, run launch script, spawn agents.
|
|
727
|
+
* Extracted from handlePlanApproval() so the CLI can call it independently after
|
|
728
|
+
* prompting "Continue to implementation?"
|
|
729
|
+
*/
|
|
730
|
+
async startImplementation(featureId) {
|
|
731
|
+
const feature = queries.getFeatureById(featureId);
|
|
732
|
+
if (!feature) return;
|
|
733
|
+
|
|
734
|
+
const planDir = path.join(feature.signal_dir, "plans");
|
|
735
|
+
const detectedRepos = detectReposFromPlan(planDir, feature.slug);
|
|
736
|
+
const meta = queries.getFeatureMetadata(featureId);
|
|
737
|
+
const newRepos = meta.new_repos || [];
|
|
738
|
+
|
|
739
|
+
try {
|
|
740
|
+
// Create feature branches
|
|
741
|
+
if (detectedRepos.length > 0 || newRepos.length > 0) {
|
|
742
|
+
const completeScript = path.join(SCRIPTS_DIR, "planning-complete.sh");
|
|
743
|
+
if (fs.existsSync(completeScript)) {
|
|
744
|
+
const newFlags = newRepos.map(nr => `--new "${nr.localPath}" "${nr.githubName}"`);
|
|
745
|
+
const newRepoPaths = new Set(newRepos.map(r => r.localPath));
|
|
746
|
+
const existingArgs = detectedRepos.filter(r => !newRepoPaths.has(r)).map(r => `"${r}"`);
|
|
747
|
+
const args = [`"${feature.slug}"`, ...newFlags, ...existingArgs].join(" ");
|
|
748
|
+
|
|
749
|
+
execSync(`bash "${completeScript}" ${args}`, {
|
|
750
|
+
cwd: IRIAI_TEAM_DIR, stdio: "pipe", timeout: 120000,
|
|
751
|
+
env: { ...process.env, PLAN_DIR: planDir, CONFIRM: "y" },
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Run launch script if it exists
|
|
757
|
+
const launchScript = path.join(SCRIPTS_DIR, "launch-feature.sh");
|
|
758
|
+
if (fs.existsSync(launchScript)) {
|
|
759
|
+
execSync(`bash "${launchScript}" "${feature.slug}"`, {
|
|
760
|
+
cwd: IRIAI_TEAM_DIR, stdio: "pipe", timeout: 300000,
|
|
761
|
+
env: {
|
|
762
|
+
...process.env,
|
|
763
|
+
IMPL_SIGNAL_BASE: IMPL_BASE,
|
|
764
|
+
SLACK_MODE: "true",
|
|
765
|
+
FEATURE_SLUG: feature.slug,
|
|
766
|
+
IMPL_CHANNEL: feature.feature_channel || "",
|
|
767
|
+
PLAN_DIR: planDir,
|
|
768
|
+
ROLES_DIR: V3_ROLES_DIR,
|
|
769
|
+
},
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Launch implementation agents
|
|
774
|
+
this.launchImplAgents(featureId);
|
|
775
|
+
|
|
776
|
+
db.transaction(() => {
|
|
777
|
+
queries.updateFeaturePhase(featureId, "impl");
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
queries.insertEvent(featureId, "phase-transition", "bridge", "Implementation launched");
|
|
781
|
+
} catch (err) {
|
|
782
|
+
console.error("[orchestrator] Implementation launch error:", err.message);
|
|
783
|
+
const stderr = err.stderr ? err.stderr.toString().slice(-500) : err.message;
|
|
784
|
+
await this.adapter.postPipelineMessage(feature.id,
|
|
785
|
+
`Error launching implementation:\n\`\`\`${stderr}\`\`\``);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Handle plan rejection — re-dispatch architect.
|
|
791
|
+
*/
|
|
792
|
+
async handlePlanRejection(featureId, reason) {
|
|
793
|
+
const feature = queries.getFeatureById(featureId);
|
|
794
|
+
if (!feature) return;
|
|
795
|
+
|
|
796
|
+
await this.adapter.postPipelineMessage(feature.id,
|
|
797
|
+
`Plan rejected. Re-dispatching Architect with feedback...`);
|
|
798
|
+
|
|
799
|
+
db.transaction(() => {
|
|
800
|
+
queries.updateFeaturePhase(featureId, "planning");
|
|
801
|
+
queries.updateFeaturePlanningRole(featureId, "architect");
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
const archDir = path.join(feature.signal_dir, "planning", "architect");
|
|
805
|
+
ensureDir(archDir);
|
|
806
|
+
writeSignal(path.join(archDir, SIGNAL.USER_MESSAGE),
|
|
807
|
+
`REVISION REQUESTED: ${reason || "Plan rejected. Please revise."}`);
|
|
808
|
+
|
|
809
|
+
this.dispatchPlanningRole(featureId, "architect");
|
|
810
|
+
queries.insertEvent(featureId, "phase-transition", "bridge", `Plan rejected: ${reason}`);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Handle repo confirmation ("go") — create branches and launch implementation.
|
|
815
|
+
*/
|
|
816
|
+
async handleRepoConfirmation(featureId) {
|
|
817
|
+
const feature = queries.getFeatureById(featureId);
|
|
818
|
+
if (!feature) return;
|
|
819
|
+
|
|
820
|
+
const meta = queries.getFeatureMetadata(featureId);
|
|
821
|
+
const repos = meta.pending_repos || [];
|
|
822
|
+
const newRepos = meta.new_repos || [];
|
|
823
|
+
meta.awaiting_repo_confirm = false;
|
|
824
|
+
meta.pending_repos = null;
|
|
825
|
+
queries.updateFeatureMetadata(featureId, meta);
|
|
826
|
+
|
|
827
|
+
const planDir = path.join(feature.signal_dir, "plans");
|
|
828
|
+
try {
|
|
829
|
+
if (repos.length > 0 || newRepos.length > 0) {
|
|
830
|
+
const totalCount = repos.length + newRepos.filter(nr => !repos.includes(nr.localPath)).length;
|
|
831
|
+
await this.adapter.postThreadMessage(feature.id,
|
|
832
|
+
`*[Pipeline]* Creating \`feature/${feature.slug}\` branches in ${totalCount} repo(s)...`);
|
|
833
|
+
|
|
834
|
+
const completeScript = path.join(SCRIPTS_DIR, "planning-complete.sh");
|
|
835
|
+
if (fs.existsSync(completeScript)) {
|
|
836
|
+
// Build --new flags for new repos
|
|
837
|
+
const newFlags = newRepos.map(nr => `--new "${nr.localPath}" "${nr.githubName}"`);
|
|
838
|
+
// Existing repos (exclude new repo paths that will be handled by --new)
|
|
839
|
+
const newRepoPaths = new Set(newRepos.map(r => r.localPath));
|
|
840
|
+
const existingArgs = repos.filter(r => !newRepoPaths.has(r)).map(r => `"${r}"`);
|
|
841
|
+
|
|
842
|
+
const args = [
|
|
843
|
+
`"${feature.slug}"`,
|
|
844
|
+
...newFlags,
|
|
845
|
+
...existingArgs,
|
|
846
|
+
].join(" ");
|
|
847
|
+
|
|
848
|
+
execSync(
|
|
849
|
+
`bash "${completeScript}" ${args}`,
|
|
850
|
+
{
|
|
851
|
+
cwd: IRIAI_TEAM_DIR, stdio: "pipe", timeout: 120000,
|
|
852
|
+
env: { ...process.env, PLAN_DIR: planDir, CONFIRM: "y" },
|
|
853
|
+
}
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
await this.adapter.postThreadMessage(feature.id,
|
|
857
|
+
`*[Pipeline]* Feature branches created. Launching implementation...`);
|
|
858
|
+
} else {
|
|
859
|
+
await this.adapter.postThreadMessage(feature.id,
|
|
860
|
+
`*[Pipeline]* No repos configured — skipping branch creation. Launching implementation...`);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const launchScript = path.join(SCRIPTS_DIR, "launch-feature.sh");
|
|
864
|
+
if (fs.existsSync(launchScript)) {
|
|
865
|
+
try {
|
|
866
|
+
execSync(`bash "${launchScript}" "${feature.slug}"`, {
|
|
867
|
+
cwd: IRIAI_TEAM_DIR, stdio: "pipe", timeout: 300000,
|
|
868
|
+
env: {
|
|
869
|
+
...process.env,
|
|
870
|
+
IMPL_SIGNAL_BASE: IMPL_BASE,
|
|
871
|
+
SLACK_MODE: "true",
|
|
872
|
+
FEATURE_SLUG: feature.slug,
|
|
873
|
+
IMPL_CHANNEL: feature.feature_channel || "",
|
|
874
|
+
PLAN_DIR: planDir,
|
|
875
|
+
ROLES_DIR: V3_ROLES_DIR,
|
|
876
|
+
},
|
|
877
|
+
});
|
|
878
|
+
} catch (launchErr) {
|
|
879
|
+
console.error("[orchestrator] launch-feature.sh failed:", launchErr.message);
|
|
880
|
+
const stderr = launchErr.stderr ? launchErr.stderr.toString().slice(-500) : launchErr.message;
|
|
881
|
+
await this.adapter.postThreadMessage(feature.id,
|
|
882
|
+
`*[Pipeline]* Error setting up implementation:\n\`\`\`${stderr}\`\`\``);
|
|
883
|
+
meta.awaiting_repo_confirm = true;
|
|
884
|
+
queries.updateFeatureMetadata(featureId, meta);
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Launch implementation agents
|
|
890
|
+
this.launchImplAgents(featureId);
|
|
891
|
+
|
|
892
|
+
db.transaction(() => {
|
|
893
|
+
queries.updateFeaturePhase(featureId, "impl");
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
queries.insertEvent(featureId, "phase-transition", "bridge", "Implementation launched");
|
|
897
|
+
} catch (err) {
|
|
898
|
+
const stderr = err.stderr ? err.stderr.toString().slice(-500) : err.message;
|
|
899
|
+
await this.adapter.postThreadMessage(feature.id,
|
|
900
|
+
`*[Pipeline]* Error creating branches:\n\`\`\`${stderr}\`\`\`\nRetry by replying "go" again.`);
|
|
901
|
+
meta.awaiting_repo_confirm = true;
|
|
902
|
+
queries.updateFeatureMetadata(featureId, meta);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
907
|
+
// IMPLEMENTATION PIPELINE
|
|
908
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Discover the signal tree for a feature slug.
|
|
912
|
+
*/
|
|
913
|
+
discoverSignalTree(slug) {
|
|
914
|
+
const featureDir = path.join(IMPL_BASE, "features", slug);
|
|
915
|
+
const tree = {
|
|
916
|
+
featureDir,
|
|
917
|
+
featureLead: null,
|
|
918
|
+
operator: null,
|
|
919
|
+
featureReview: {},
|
|
920
|
+
teams: {},
|
|
921
|
+
planning: {},
|
|
922
|
+
plansDir: null,
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
const flDir = path.join(featureDir, "feature-lead");
|
|
926
|
+
if (fs.existsSync(flDir)) tree.featureLead = flDir;
|
|
927
|
+
|
|
928
|
+
const opDir = path.join(featureDir, "operator");
|
|
929
|
+
if (fs.existsSync(opDir)) tree.operator = opDir;
|
|
930
|
+
|
|
931
|
+
const reviewDir = path.join(featureDir, "feature-review");
|
|
932
|
+
if (fs.existsSync(reviewDir)) {
|
|
933
|
+
try {
|
|
934
|
+
for (const entry of fs.readdirSync(reviewDir, { withFileTypes: true })) {
|
|
935
|
+
if (entry.isDirectory()) {
|
|
936
|
+
tree.featureReview[entry.name] = path.join(reviewDir, entry.name);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
} catch { /* ok */ }
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const teamsDir = path.join(featureDir, "teams");
|
|
943
|
+
if (fs.existsSync(teamsDir)) {
|
|
944
|
+
try {
|
|
945
|
+
for (const entry of fs.readdirSync(teamsDir, { withFileTypes: true })) {
|
|
946
|
+
if (!entry.isDirectory() || !entry.name.startsWith("team-")) continue;
|
|
947
|
+
const teamNum = entry.name.replace("team-", "");
|
|
948
|
+
const teamDir = path.join(teamsDir, entry.name);
|
|
949
|
+
const orchDir = path.join(teamDir, "orchestrator");
|
|
950
|
+
const roles = {};
|
|
951
|
+
const rolesDir = path.join(teamDir, "roles");
|
|
952
|
+
if (fs.existsSync(rolesDir)) {
|
|
953
|
+
for (const roleEntry of fs.readdirSync(rolesDir, { withFileTypes: true })) {
|
|
954
|
+
if (roleEntry.isDirectory()) {
|
|
955
|
+
roles[roleEntry.name] = path.join(rolesDir, roleEntry.name);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
tree.teams[teamNum] = {
|
|
960
|
+
dir: teamDir,
|
|
961
|
+
orchestrator: fs.existsSync(orchDir) ? orchDir : null,
|
|
962
|
+
roles,
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
} catch { /* ok */ }
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Planning dirs
|
|
969
|
+
const planningDir = path.join(featureDir, "planning");
|
|
970
|
+
if (fs.existsSync(planningDir)) {
|
|
971
|
+
for (const role of PLANNING_ROLES) {
|
|
972
|
+
const roleDir = path.join(planningDir, role);
|
|
973
|
+
if (fs.existsSync(roleDir)) tree.planning[role] = roleDir;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const plansDir = path.join(featureDir, "plans");
|
|
978
|
+
if (fs.existsSync(plansDir)) tree.plansDir = plansDir;
|
|
979
|
+
|
|
980
|
+
this._signalTrees[slug] = tree;
|
|
981
|
+
return tree;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Create per-team worktrees from the feature-level repos.
|
|
986
|
+
* For each repo at .features/<slug>/repos/<basename>/ (on feature/<slug> branch),
|
|
987
|
+
* creates .features/<slug>/teams/<teamNum>/repos/<basename>/ on feature/<slug>/team-<teamNum> branch.
|
|
988
|
+
*/
|
|
989
|
+
_createTeamWorktrees(feature, numTeams) {
|
|
990
|
+
const featuresBase = path.join(process.env.HOME, "src/iriai/.features", feature.slug);
|
|
991
|
+
const reposDir = path.join(featuresBase, "repos");
|
|
992
|
+
if (!fs.existsSync(reposDir)) return;
|
|
993
|
+
|
|
994
|
+
const repos = fs.readdirSync(reposDir).filter(name => {
|
|
995
|
+
const repoPath = path.join(reposDir, name);
|
|
996
|
+
// Worktrees have a .git file (not directory), regular repos have .git directory
|
|
997
|
+
return fs.statSync(repoPath).isDirectory() && fs.existsSync(path.join(repoPath, ".git"));
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
if (repos.length === 0) return;
|
|
1001
|
+
|
|
1002
|
+
const featureBranch = `feature/${feature.slug}`;
|
|
1003
|
+
|
|
1004
|
+
for (let teamNum = 1; teamNum <= numTeams; teamNum++) {
|
|
1005
|
+
const teamReposDir = path.join(featuresBase, "teams", String(teamNum), "repos");
|
|
1006
|
+
ensureDir(teamReposDir);
|
|
1007
|
+
|
|
1008
|
+
for (const repoName of repos) {
|
|
1009
|
+
const featureRepoPath = path.join(reposDir, repoName);
|
|
1010
|
+
const teamWorktreeDest = path.join(teamReposDir, repoName);
|
|
1011
|
+
const teamBranch = `${featureBranch}/team-${teamNum}`;
|
|
1012
|
+
|
|
1013
|
+
// Skip if team worktree already exists
|
|
1014
|
+
if (fs.existsSync(teamWorktreeDest)) continue;
|
|
1015
|
+
|
|
1016
|
+
try {
|
|
1017
|
+
// Create team branch from feature branch if it doesn't exist
|
|
1018
|
+
try {
|
|
1019
|
+
execSync(`git -C "${featureRepoPath}" branch "${teamBranch}" "${featureBranch}" 2>/dev/null || true`, { stdio: "pipe" });
|
|
1020
|
+
} catch { /* branch may already exist */ }
|
|
1021
|
+
|
|
1022
|
+
// Create worktree for this team
|
|
1023
|
+
execSync(
|
|
1024
|
+
`git -C "${featureRepoPath}" worktree add "${teamWorktreeDest}" "${teamBranch}"`,
|
|
1025
|
+
{ stdio: "pipe", timeout: 30000 }
|
|
1026
|
+
);
|
|
1027
|
+
console.log(`[orchestrator] Created team worktree: ${repoName} → team-${teamNum} (${teamBranch})`);
|
|
1028
|
+
} catch (err) {
|
|
1029
|
+
// Try with -b flag as fallback
|
|
1030
|
+
try {
|
|
1031
|
+
execSync(
|
|
1032
|
+
`git -C "${featureRepoPath}" worktree add "${teamWorktreeDest}" -b "${teamBranch}" 2>/dev/null || git -C "${featureRepoPath}" worktree add "${teamWorktreeDest}" "${teamBranch}"`,
|
|
1033
|
+
{ stdio: "pipe", timeout: 30000 }
|
|
1034
|
+
);
|
|
1035
|
+
console.log(`[orchestrator] Created team worktree (fallback): ${repoName} → team-${teamNum}`);
|
|
1036
|
+
} catch (err2) {
|
|
1037
|
+
console.error(`[orchestrator] Failed to create team worktree for ${repoName} team-${teamNum}:`, err2.message);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Launch all implementation agents for a feature.
|
|
1046
|
+
*/
|
|
1047
|
+
launchImplAgents(featureId) {
|
|
1048
|
+
const feature = queries.getFeatureById(featureId);
|
|
1049
|
+
if (!feature) return;
|
|
1050
|
+
|
|
1051
|
+
const tree = this.discoverSignalTree(feature.slug);
|
|
1052
|
+
const numTeams = Object.keys(tree.teams).length || feature.num_teams;
|
|
1053
|
+
const worktreeDir = path.join(process.env.HOME, "src/iriai/.features", feature.slug);
|
|
1054
|
+
const featureCwd = fs.existsSync(worktreeDir) ? worktreeDir : IRIAI_TEAM_DIR;
|
|
1055
|
+
|
|
1056
|
+
// Create per-team worktrees so each team works on its own branch
|
|
1057
|
+
this._createTeamWorktrees(feature, numTeams);
|
|
1058
|
+
|
|
1059
|
+
// Feature Lead
|
|
1060
|
+
if (tree.featureLead) {
|
|
1061
|
+
let flAgent = queries.getAgentByKey(`fl-${feature.slug}`);
|
|
1062
|
+
if (!flAgent) {
|
|
1063
|
+
flAgent = queries.createAgent({
|
|
1064
|
+
featureId,
|
|
1065
|
+
agentType: "feature-lead",
|
|
1066
|
+
agentKey: `fl-${feature.slug}`,
|
|
1067
|
+
roleName: "feature-lead",
|
|
1068
|
+
signalDir: tree.featureLead,
|
|
1069
|
+
cwd: featureCwd,
|
|
1070
|
+
maxRetries: MAX_FL_RETRIES,
|
|
1071
|
+
});
|
|
1072
|
+
} else {
|
|
1073
|
+
this._syncAgentRetries(flAgent, MAX_FL_RETRIES);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// Use "refresh" mode if FEATURE-STATUS.md exists (recovery/restart scenario)
|
|
1077
|
+
const featureStatusPath = path.join(tree.featureDir, "FEATURE-STATUS.md");
|
|
1078
|
+
const flMode = fs.existsSync(featureStatusPath) ? "refresh" : "init";
|
|
1079
|
+
// On recovery: if FL has prior session and no .handover, use --continue
|
|
1080
|
+
const flHasHandover = fs.existsSync(path.join(tree.featureLead, SIGNAL.HANDOVER));
|
|
1081
|
+
const flHasSession = flAgent.started_at != null;
|
|
1082
|
+
const flContinue = flHasSession && !flHasHandover && flMode === "refresh";
|
|
1083
|
+
this._spawnFeatureLead(flAgent.id, feature, tree, flMode, { continue: flContinue });
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Operator
|
|
1087
|
+
if (tree.operator) {
|
|
1088
|
+
let opAgent = queries.getAgentByKey(`op-${feature.slug}`);
|
|
1089
|
+
if (!opAgent) {
|
|
1090
|
+
opAgent = queries.createAgent({
|
|
1091
|
+
featureId,
|
|
1092
|
+
agentType: "operator",
|
|
1093
|
+
agentKey: `op-${feature.slug}`,
|
|
1094
|
+
roleName: "operator",
|
|
1095
|
+
signalDir: tree.operator,
|
|
1096
|
+
cwd: featureCwd,
|
|
1097
|
+
maxRetries: MAX_OPERATOR_RETRIES,
|
|
1098
|
+
});
|
|
1099
|
+
} else {
|
|
1100
|
+
this._syncAgentRetries(opAgent, MAX_OPERATOR_RETRIES);
|
|
1101
|
+
}
|
|
1102
|
+
// Operator is reactive — spawned by routeUserMessage
|
|
1103
|
+
queries.updateAgentStatus(opAgent.id, "idle");
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// Team Orchestrators
|
|
1107
|
+
for (const [teamNum, team] of Object.entries(tree.teams)) {
|
|
1108
|
+
// Per-team CWD: use team-specific worktree if it exists, else fall back to feature-level
|
|
1109
|
+
const teamWorktreeDir = path.join(process.env.HOME, "src/iriai/.features", feature.slug, "teams", teamNum, "repos");
|
|
1110
|
+
const teamCwd = fs.existsSync(teamWorktreeDir) ? path.join(process.env.HOME, "src/iriai/.features", feature.slug, "teams", teamNum) : featureCwd;
|
|
1111
|
+
|
|
1112
|
+
if (team.orchestrator) {
|
|
1113
|
+
const orchKey = `orch-${feature.slug}-${teamNum}`;
|
|
1114
|
+
let orchAgent = queries.getAgentByKey(orchKey);
|
|
1115
|
+
if (!orchAgent) {
|
|
1116
|
+
orchAgent = queries.createAgent({
|
|
1117
|
+
featureId,
|
|
1118
|
+
agentType: "team-orchestrator",
|
|
1119
|
+
agentKey: orchKey,
|
|
1120
|
+
teamNum,
|
|
1121
|
+
signalDir: team.orchestrator,
|
|
1122
|
+
cwd: teamCwd,
|
|
1123
|
+
maxRetries: MAX_ORCH_RETRIES,
|
|
1124
|
+
});
|
|
1125
|
+
} else {
|
|
1126
|
+
this._syncAgentRetries(orchAgent, MAX_ORCH_RETRIES);
|
|
1127
|
+
}
|
|
1128
|
+
// Orchestrator waits for .task — no auto-start
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// Role Agents
|
|
1132
|
+
for (const [role, roleDir] of Object.entries(team.roles)) {
|
|
1133
|
+
const roleKey = `role-${feature.slug}-${teamNum}-${role}`;
|
|
1134
|
+
let roleAgent = queries.getAgentByKey(roleKey);
|
|
1135
|
+
if (!roleAgent) {
|
|
1136
|
+
roleAgent = queries.createAgent({
|
|
1137
|
+
featureId,
|
|
1138
|
+
agentType: "role-agent",
|
|
1139
|
+
agentKey: roleKey,
|
|
1140
|
+
roleName: role,
|
|
1141
|
+
teamNum,
|
|
1142
|
+
signalDir: roleDir,
|
|
1143
|
+
cwd: teamCwd,
|
|
1144
|
+
maxRetries: MAX_ROLE_RETRIES,
|
|
1145
|
+
});
|
|
1146
|
+
} else {
|
|
1147
|
+
this._syncAgentRetries(roleAgent, MAX_ROLE_RETRIES);
|
|
1148
|
+
}
|
|
1149
|
+
// Role waits for .task — no auto-start
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
// Feature Review Roles
|
|
1154
|
+
for (const [role, reviewDir] of Object.entries(tree.featureReview)) {
|
|
1155
|
+
const reviewKey = `review-${feature.slug}-${role}`;
|
|
1156
|
+
let reviewAgent = queries.getAgentByKey(reviewKey);
|
|
1157
|
+
if (!reviewAgent) {
|
|
1158
|
+
reviewAgent = queries.createAgent({
|
|
1159
|
+
featureId,
|
|
1160
|
+
agentType: "review-agent",
|
|
1161
|
+
agentKey: reviewKey,
|
|
1162
|
+
roleName: role,
|
|
1163
|
+
signalDir: reviewDir,
|
|
1164
|
+
cwd: featureCwd,
|
|
1165
|
+
maxRetries: MAX_ROLE_RETRIES,
|
|
1166
|
+
});
|
|
1167
|
+
} else {
|
|
1168
|
+
this._syncAgentRetries(reviewAgent, MAX_ROLE_RETRIES);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// Watch signals
|
|
1173
|
+
this.fileIO.watchFeatureSignals(feature.slug, tree);
|
|
1174
|
+
|
|
1175
|
+
console.log(`[orchestrator] Launched impl agents for ${feature.slug}: FL + operator + ${numTeams} teams`);
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Sync max_retries from current constants to existing DB agent records.
|
|
1180
|
+
*/
|
|
1181
|
+
_syncAgentRetries(agent, expectedMaxRetries) {
|
|
1182
|
+
if (agent.max_retries !== expectedMaxRetries) {
|
|
1183
|
+
queries.updateAgentMaxRetries(agent.id, expectedMaxRetries);
|
|
1184
|
+
}
|
|
1185
|
+
// Reset crashed agents so they can be retried
|
|
1186
|
+
if (agent.status === "crashed") {
|
|
1187
|
+
queries.updateAgentStatus(agent.id, "idle");
|
|
1188
|
+
queries.resetAgentRetry(agent.id);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Spawn (or re-spawn) the Feature Lead agent.
|
|
1194
|
+
*/
|
|
1195
|
+
_spawnFeatureLead(agentId, feature, tree, mode, opts = {}) {
|
|
1196
|
+
const numTeams = Object.keys(tree.teams).length || feature.num_teams;
|
|
1197
|
+
const teamSignalBase = path.join(tree.featureDir, "teams");
|
|
1198
|
+
const featureReviewDir = path.join(tree.featureDir, "feature-review");
|
|
1199
|
+
|
|
1200
|
+
// Resolve plan read instruction
|
|
1201
|
+
const planReadInstruction = this._resolvePlanInstruction(tree.featureLead, tree.featureDir);
|
|
1202
|
+
|
|
1203
|
+
let prompt;
|
|
1204
|
+
switch (mode) {
|
|
1205
|
+
case "init":
|
|
1206
|
+
prompt = buildFeatureLeadInitPrompt({
|
|
1207
|
+
featureName: feature.slug,
|
|
1208
|
+
numTeams,
|
|
1209
|
+
teamType: "dynamic",
|
|
1210
|
+
teamSignalBase,
|
|
1211
|
+
planReadInstruction,
|
|
1212
|
+
featureLeadDir: tree.featureLead,
|
|
1213
|
+
featureReviewDir,
|
|
1214
|
+
dashboardLog: path.join(tree.featureDir, ".dashboard-log"),
|
|
1215
|
+
featureDir: tree.featureDir,
|
|
1216
|
+
teamWorktreeBase: path.join(process.env.HOME, "src/iriai/.features", feature.slug, "teams"),
|
|
1217
|
+
});
|
|
1218
|
+
break;
|
|
1219
|
+
|
|
1220
|
+
case "refresh":
|
|
1221
|
+
prompt = buildFeatureLeadRefreshPrompt({
|
|
1222
|
+
featureName: feature.slug,
|
|
1223
|
+
numTeams,
|
|
1224
|
+
teamSignalBase,
|
|
1225
|
+
planReadInstruction,
|
|
1226
|
+
featureLeadDir: tree.featureLead,
|
|
1227
|
+
featureReviewDir,
|
|
1228
|
+
dashboardLog: path.join(tree.featureDir, ".dashboard-log"),
|
|
1229
|
+
gateEvidenceTs: feature.gate_evidence_ts,
|
|
1230
|
+
featureDir: tree.featureDir,
|
|
1231
|
+
teamWorktreeBase: path.join(process.env.HOME, "src/iriai/.features", feature.slug, "teams"),
|
|
1232
|
+
});
|
|
1233
|
+
break;
|
|
1234
|
+
|
|
1235
|
+
case "trigger":
|
|
1236
|
+
prompt = buildFeatureLeadTriggerPrompt({
|
|
1237
|
+
featureName: feature.slug,
|
|
1238
|
+
numTeams,
|
|
1239
|
+
trigger: opts.trigger,
|
|
1240
|
+
teamSignalBase,
|
|
1241
|
+
featureLeadDir: tree.featureLead,
|
|
1242
|
+
featureReviewDir,
|
|
1243
|
+
dashboardLog: path.join(tree.featureDir, ".dashboard-log"),
|
|
1244
|
+
questionTeams: opts.questionTeams,
|
|
1245
|
+
crashedTeams: opts.crashedTeams,
|
|
1246
|
+
recoveryContext: opts.recoveryContext,
|
|
1247
|
+
featureDir: tree.featureDir,
|
|
1248
|
+
teamWorktreeBase: path.join(process.env.HOME, "src/iriai/.features", feature.slug, "teams"),
|
|
1249
|
+
});
|
|
1250
|
+
break;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// Prepend handover content if available
|
|
1254
|
+
if (opts.handoverContent) {
|
|
1255
|
+
prompt = `HANDOVER CONTEXT FROM PREVIOUS SESSION:\n${opts.handoverContent}\n\n---\n\n${prompt}`;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// Use --continue if FL has prior session and this isn't a fresh start (handover/refresh)
|
|
1259
|
+
// Handover = context exhausted, must start fresh. Trigger = can continue prior conversation.
|
|
1260
|
+
const useContinue = opts.continue !== undefined
|
|
1261
|
+
? opts.continue
|
|
1262
|
+
: (!opts.handoverContent && mode === "trigger");
|
|
1263
|
+
this.supervisor.spawn(agentId, prompt, { continue: useContinue });
|
|
1264
|
+
queries.insertEvent(feature.id, "agent-started", "bridge", `Feature Lead spawned (${mode})`);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
_resolvePlanInstruction(featureLeadDir, featureDir) {
|
|
1268
|
+
// Check for .feature-proposal with custom plan paths
|
|
1269
|
+
const proposalPath = path.join(featureLeadDir, ".feature-proposal");
|
|
1270
|
+
if (fs.existsSync(proposalPath)) {
|
|
1271
|
+
const content = fs.readFileSync(proposalPath, "utf-8");
|
|
1272
|
+
const planMatch = content.match(/plan_path:\s*(.+)/);
|
|
1273
|
+
if (planMatch) return `Read the implementation plan at: ${planMatch[1].trim()}`;
|
|
1274
|
+
}
|
|
1275
|
+
// Check per-feature plans dir first
|
|
1276
|
+
const perFeaturePlansDir = path.join(featureDir, "plans");
|
|
1277
|
+
if (fs.existsSync(perFeaturePlansDir)) {
|
|
1278
|
+
return `Read the implementation plan in ${perFeaturePlansDir}/`;
|
|
1279
|
+
}
|
|
1280
|
+
return `Read the implementation plan in ~/src/iriai/iriai-team/implementation-plans/current/`;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// ─── Gate Handling ──────────────────────────────────────────────────────
|
|
1284
|
+
|
|
1285
|
+
async handleGateApproval(featureId) {
|
|
1286
|
+
const feature = queries.getFeatureById(featureId);
|
|
1287
|
+
if (!feature) return;
|
|
1288
|
+
const tree = this._signalTrees[feature.slug];
|
|
1289
|
+
if (!tree?.featureLead) return;
|
|
1290
|
+
|
|
1291
|
+
writeSignal(path.join(tree.featureLead, SIGNAL.USER_MESSAGE),
|
|
1292
|
+
"GATE APPROVED. Advance all teams to the next phase.");
|
|
1293
|
+
|
|
1294
|
+
queries.updateFeatureGateEvidenceTs(featureId, null);
|
|
1295
|
+
queries.updateFeatureGate(featureId, (feature.gate_number || 0) + 1);
|
|
1296
|
+
queries.insertEvent(featureId, "gate-approved", "bridge", `Gate ${(feature.gate_number || 0) + 1} approved by user`);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
async handleGateRejection(featureId, reason) {
|
|
1300
|
+
const feature = queries.getFeatureById(featureId);
|
|
1301
|
+
if (!feature) return;
|
|
1302
|
+
const tree = this._signalTrees[feature.slug];
|
|
1303
|
+
if (!tree?.featureLead) return;
|
|
1304
|
+
|
|
1305
|
+
writeSignal(path.join(tree.featureLead, SIGNAL.USER_MESSAGE),
|
|
1306
|
+
`GATE REJECTED: ${reason || "Please revise and resubmit."}`);
|
|
1307
|
+
|
|
1308
|
+
queries.updateFeatureGateEvidenceTs(featureId, null);
|
|
1309
|
+
queries.insertEvent(featureId, "gate-rejected", "bridge", `Gate rejected: ${reason}`);
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// ─── Decision Click Routing (Block Kit Buttons) ──────────────────────
|
|
1313
|
+
|
|
1314
|
+
async handleDecisionClick(decisionId, optionId, userId, channel, messageTs, feedback = "") {
|
|
1315
|
+
// Collect annotations from review session and enrich rejection feedback
|
|
1316
|
+
let enrichedFeedback = feedback;
|
|
1317
|
+
if (this.reviewSessions && optionId !== "approve") {
|
|
1318
|
+
const annotations = await this.reviewSessions.collectFeedback(decisionId);
|
|
1319
|
+
if (annotations && annotations.length > 0) {
|
|
1320
|
+
enrichedFeedback = formatAnnotationsAsFeedback(annotations, feedback);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// Phase review: "phase-review-<slug>-<role>" (or legacy "phase-review-<role>")
|
|
1325
|
+
if (decisionId.startsWith("phase-review-")) {
|
|
1326
|
+
const suffix = decisionId.replace("phase-review-", "");
|
|
1327
|
+
const features = queries.getActiveFeatures();
|
|
1328
|
+
|
|
1329
|
+
// New format: "phase-review-<slug>-<role>" — find feature by slug embedded in the ID
|
|
1330
|
+
let feature = features.find(f => {
|
|
1331
|
+
const m = queries.getFeatureMetadata(f.id);
|
|
1332
|
+
if (!m.awaiting_phase_review) return false;
|
|
1333
|
+
return suffix === `${f.slug}-${m.phase_review_role}`;
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
// Legacy fallback: "phase-review-<role>" — disambiguate by channel
|
|
1337
|
+
if (!feature) {
|
|
1338
|
+
const role = suffix;
|
|
1339
|
+
feature = features.find(f => {
|
|
1340
|
+
const m = queries.getFeatureMetadata(f.id);
|
|
1341
|
+
if (!m.awaiting_phase_review || m.phase_review_role !== role) return false;
|
|
1342
|
+
const fChannel = f.feature_channel || this.adapter.planningChannel;
|
|
1343
|
+
return fChannel === channel;
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
if (!feature) return;
|
|
1347
|
+
|
|
1348
|
+
if (optionId === "approve") {
|
|
1349
|
+
await this.handlePhaseReviewApproval(feature.id);
|
|
1350
|
+
} else {
|
|
1351
|
+
await this.handlePhaseReviewRejection(feature.id, enrichedFeedback || "Rejected via button");
|
|
1352
|
+
}
|
|
1353
|
+
await this._resolveDecisionMessage(feature.id, messageTs, decisionId, optionId, userId, feedback);
|
|
1354
|
+
if (this.reviewSessions) {
|
|
1355
|
+
await this.reviewSessions.stop(decisionId);
|
|
1356
|
+
// Also stop mockup session if this was a designer review
|
|
1357
|
+
await this.reviewSessions.stop(`${decisionId}-mockup`);
|
|
1358
|
+
}
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// Plan approval: "plan-approval"
|
|
1363
|
+
if (decisionId === "plan-approval") {
|
|
1364
|
+
const features = queries.getActiveFeatures();
|
|
1365
|
+
// Use channel to disambiguate when multiple features are in plan-approval
|
|
1366
|
+
const feature = features.find(f => {
|
|
1367
|
+
if (f.phase !== "plan-approval") return false;
|
|
1368
|
+
const fChannel = f.feature_channel || this.adapter.planningChannel;
|
|
1369
|
+
return fChannel === channel;
|
|
1370
|
+
});
|
|
1371
|
+
if (!feature) return;
|
|
1372
|
+
|
|
1373
|
+
if (optionId === "approve") {
|
|
1374
|
+
await this.handlePlanApproval(feature.id);
|
|
1375
|
+
} else {
|
|
1376
|
+
await this.handlePlanRejection(feature.id, enrichedFeedback || "Rejected via button");
|
|
1377
|
+
}
|
|
1378
|
+
await this._resolveDecisionMessage(feature.id, messageTs, decisionId, optionId, userId, feedback);
|
|
1379
|
+
if (this.reviewSessions) await this.reviewSessions.stop(decisionId);
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Gate approval: "gate-*"
|
|
1384
|
+
if (decisionId.startsWith("gate-")) {
|
|
1385
|
+
const features = queries.getActiveFeatures();
|
|
1386
|
+
const feature = features.find(f => f.feature_channel === channel && f.gate_evidence_ts);
|
|
1387
|
+
if (!feature) return;
|
|
1388
|
+
|
|
1389
|
+
if (optionId === "approve") {
|
|
1390
|
+
await this.handleGateApproval(feature.id);
|
|
1391
|
+
} else {
|
|
1392
|
+
await this.handleGateRejection(feature.id, enrichedFeedback || "Rejected via button");
|
|
1393
|
+
}
|
|
1394
|
+
await this._resolveDecisionMessage(feature.id, messageTs, decisionId, optionId, userId, feedback);
|
|
1395
|
+
if (this.reviewSessions) await this.reviewSessions.stop(decisionId);
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// Custom [DECISION] — resolve in DB, notify agent
|
|
1400
|
+
// Use slack_channel stored on the decision to match the correct feature
|
|
1401
|
+
const decision = queries.getDecisionByChannel(channel, decisionId);
|
|
1402
|
+
if (decision) {
|
|
1403
|
+
queries.resolveDecision(decision.feature_id, decisionId, { selectedOption: optionId, resolvedBy: userId });
|
|
1404
|
+
await this._resolveDecisionMessage(decision.feature_id, messageTs, decisionId, optionId, userId, feedback);
|
|
1405
|
+
// Write decision result to operator for relay to active agent
|
|
1406
|
+
const feature = queries.getFeatureById(decision.feature_id);
|
|
1407
|
+
if (feature) {
|
|
1408
|
+
const tree = this._signalTrees[feature.slug];
|
|
1409
|
+
const targetDir = tree?.operator || tree?.featureLead;
|
|
1410
|
+
if (targetDir) {
|
|
1411
|
+
const options = JSON.parse(decision.options || "[]");
|
|
1412
|
+
const option = options.find(o => o.id === optionId);
|
|
1413
|
+
const feedbackSuffix = feedback ? `\nFeedback: ${feedback}` : "";
|
|
1414
|
+
writeSignal(path.join(targetDir, SIGNAL.USER_MESSAGE),
|
|
1415
|
+
`Decision "${decision.title}": User selected "${option?.label || optionId}"${feedbackSuffix}`);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
async _resolveDecisionMessage(featureId, messageTs, decisionId, selectedOptionId, userId, feedback = "") {
|
|
1423
|
+
if (!featureId) return;
|
|
1424
|
+
|
|
1425
|
+
// Resolve the human-readable label for the selected option.
|
|
1426
|
+
// First check DB (custom [DECISION] blocks are stored there), then fall back to defaults.
|
|
1427
|
+
let selectedLabel = selectedOptionId;
|
|
1428
|
+
const dbDecision = queries.getDecision(featureId, decisionId);
|
|
1429
|
+
if (dbDecision) {
|
|
1430
|
+
const options = JSON.parse(dbDecision.options || "[]");
|
|
1431
|
+
const match = options.find(o => o.id === selectedOptionId);
|
|
1432
|
+
if (match) selectedLabel = match.label;
|
|
1433
|
+
} else {
|
|
1434
|
+
// Built-in decisions (phase-review, plan-approval, gate) aren't in the DB
|
|
1435
|
+
const FALLBACK_LABELS = { approve: "Approve", reject: "Reject" };
|
|
1436
|
+
if (FALLBACK_LABELS[selectedOptionId]) {
|
|
1437
|
+
selectedLabel = FALLBACK_LABELS[selectedOptionId];
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
try {
|
|
1442
|
+
await this.adapter.resolveDecisionMessage(
|
|
1443
|
+
featureId, messageTs, decisionId, selectedOptionId, selectedLabel, userId, feedback
|
|
1444
|
+
);
|
|
1445
|
+
} catch (err) {
|
|
1446
|
+
console.error("[orchestrator] Failed to update decision message:", err.message);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// ─── User Message Routing ─────────────────────────────────────────────
|
|
1451
|
+
|
|
1452
|
+
async routeUserMessage(featureId, text) {
|
|
1453
|
+
const feature = queries.getFeatureById(featureId);
|
|
1454
|
+
if (!feature) return;
|
|
1455
|
+
|
|
1456
|
+
let enrichedText = text;
|
|
1457
|
+
|
|
1458
|
+
// If the user's message matches a pending decision option and there are
|
|
1459
|
+
// doc review annotations, enrich the message so the Operator has them.
|
|
1460
|
+
const trimmed = text.trim().toLowerCase();
|
|
1461
|
+
const pendingDecision = this._findPendingDecisionForFeature(feature);
|
|
1462
|
+
if (pendingDecision && this.reviewSessions) {
|
|
1463
|
+
const matchedOption = pendingDecision.options.find(
|
|
1464
|
+
o => o.id.toLowerCase() === trimmed
|
|
1465
|
+
);
|
|
1466
|
+
if (matchedOption) {
|
|
1467
|
+
const annotations = await this.reviewSessions.collectFeedback(pendingDecision.id);
|
|
1468
|
+
if (annotations && annotations.length > 0) {
|
|
1469
|
+
const formatted = formatAnnotationsAsFeedback(annotations, null);
|
|
1470
|
+
enrichedText = `${text}\n\n${formatted}`;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
const tree = this._signalTrees[feature.slug];
|
|
1476
|
+
|
|
1477
|
+
// Route to operator, with FL fallback
|
|
1478
|
+
const targetDir = tree?.operator || tree?.featureLead;
|
|
1479
|
+
if (!targetDir) return;
|
|
1480
|
+
ensureDir(targetDir);
|
|
1481
|
+
writeSignal(path.join(targetDir, SIGNAL.USER_MESSAGE), enrichedText);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* Find the active pending decision for a feature (phase review, plan approval, or gate).
|
|
1486
|
+
* Returns { id, options } or null.
|
|
1487
|
+
*/
|
|
1488
|
+
_findPendingDecisionForFeature(feature) {
|
|
1489
|
+
const meta = queries.getFeatureMetadata(feature.id);
|
|
1490
|
+
|
|
1491
|
+
// Phase review
|
|
1492
|
+
if (meta.awaiting_phase_review && meta.phase_review_role) {
|
|
1493
|
+
return {
|
|
1494
|
+
id: `phase-review-${feature.slug}-${meta.phase_review_role}`,
|
|
1495
|
+
options: [
|
|
1496
|
+
{ id: "approve", label: "Approve" },
|
|
1497
|
+
{ id: "reject", label: "Request Revisions" },
|
|
1498
|
+
],
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// Plan approval
|
|
1503
|
+
if (feature.phase === "plan-approval") {
|
|
1504
|
+
return {
|
|
1505
|
+
id: "plan-approval",
|
|
1506
|
+
options: [
|
|
1507
|
+
{ id: "approve", label: "Approve Plan" },
|
|
1508
|
+
{ id: "reject", label: "Reject Plan" },
|
|
1509
|
+
],
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// Gate review
|
|
1514
|
+
if (feature.gate_evidence_ts) {
|
|
1515
|
+
return {
|
|
1516
|
+
id: `gate-${(feature.gate_number || 0) + 1}-review`,
|
|
1517
|
+
options: [
|
|
1518
|
+
{ id: "approve", label: "Approve" },
|
|
1519
|
+
{ id: "reject", label: "Reject" },
|
|
1520
|
+
],
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// Custom decision from DB
|
|
1525
|
+
const dbDecision = queries.getPendingDecision(feature.id);
|
|
1526
|
+
if (dbDecision) {
|
|
1527
|
+
return {
|
|
1528
|
+
id: dbDecision.decision_id,
|
|
1529
|
+
options: JSON.parse(dbDecision.options || "[]"),
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
return null;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
/**
|
|
1537
|
+
* Read the verification config from plan.yaml for a feature.
|
|
1538
|
+
* Returns { type, command, url } or null if not present.
|
|
1539
|
+
*/
|
|
1540
|
+
_readVerificationConfig(feature) {
|
|
1541
|
+
try {
|
|
1542
|
+
const planYamlPath = path.join(feature.signal_dir, "plans", "plan.yaml");
|
|
1543
|
+
if (!fs.existsSync(planYamlPath)) return null;
|
|
1544
|
+
const content = fs.readFileSync(planYamlPath, "utf-8");
|
|
1545
|
+
|
|
1546
|
+
// Extract the verification block (simple YAML subset — no nested objects)
|
|
1547
|
+
const verMatch = content.match(/^verification:\s*\n((?:\s+\S.*\n?)*)/m);
|
|
1548
|
+
if (!verMatch) return null;
|
|
1549
|
+
|
|
1550
|
+
const block = verMatch[1];
|
|
1551
|
+
const type = block.match(/type:\s*(.+)/)?.[1]?.trim().replace(/["']/g, "") || null;
|
|
1552
|
+
const command = block.match(/command:\s*["']?(.+?)["']?\s*$/m)?.[1]?.trim() || null;
|
|
1553
|
+
const url = block.match(/url:\s*["']?(.+?)["']?\s*$/m)?.[1]?.trim() || null;
|
|
1554
|
+
|
|
1555
|
+
if (!type) return null;
|
|
1556
|
+
return { type, command, url };
|
|
1557
|
+
} catch {
|
|
1558
|
+
return null;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
// ─── Worktree Management ────────────────────────────────────────────
|
|
1563
|
+
|
|
1564
|
+
/**
|
|
1565
|
+
* Handle .needs-repos signal from Operator — create git worktrees for requested repos.
|
|
1566
|
+
* Each repo path (from DIRECTORY_MAP) gets a worktree at .features/<slug>/repos/<basename>/
|
|
1567
|
+
*/
|
|
1568
|
+
async _handleNeedsRepos(feature, content) {
|
|
1569
|
+
const lines = content.split("\n").map(l => l.trim()).filter(Boolean);
|
|
1570
|
+
if (lines.length === 0) return;
|
|
1571
|
+
|
|
1572
|
+
const reposBase = path.join(process.env.HOME, "src/iriai/.features", feature.slug, "repos");
|
|
1573
|
+
ensureDir(reposBase);
|
|
1574
|
+
|
|
1575
|
+
const featureBranch = `feature/${feature.slug}`;
|
|
1576
|
+
const projectRoot = path.join(process.env.HOME, "src/iriai");
|
|
1577
|
+
const created = [];
|
|
1578
|
+
const scaffolded = [];
|
|
1579
|
+
const skipped = [];
|
|
1580
|
+
const failed = [];
|
|
1581
|
+
|
|
1582
|
+
// Separate new repos (+prefix) from existing repos
|
|
1583
|
+
const existingRepoPaths = [];
|
|
1584
|
+
const newRepoSpecs = [];
|
|
1585
|
+
for (const line of lines) {
|
|
1586
|
+
if (line.startsWith("+")) {
|
|
1587
|
+
// Format: +<localPath>:<githubName>[:<template>]
|
|
1588
|
+
const parts = line.slice(1).split(":");
|
|
1589
|
+
if (parts.length >= 2) {
|
|
1590
|
+
newRepoSpecs.push({
|
|
1591
|
+
localPath: parts[0],
|
|
1592
|
+
githubName: parts[1],
|
|
1593
|
+
template: parts[2] || null,
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
} else {
|
|
1597
|
+
existingRepoPaths.push(line);
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// Handle new repos: scaffold locally, then proceed to worktree like existing
|
|
1602
|
+
const meta = queries.getFeatureMetadata(feature.id);
|
|
1603
|
+
const existingNewRepos = meta.new_repos || [];
|
|
1604
|
+
const existingPaths = new Set(existingNewRepos.map(r => r.localPath));
|
|
1605
|
+
|
|
1606
|
+
for (const spec of newRepoSpecs) {
|
|
1607
|
+
const repoName = path.basename(spec.localPath);
|
|
1608
|
+
const worktreeDest = path.join(reposBase, repoName);
|
|
1609
|
+
|
|
1610
|
+
// Dedup: skip if already scaffolded in a prior .needs-repos call
|
|
1611
|
+
if (fs.existsSync(worktreeDest)) {
|
|
1612
|
+
skipped.push(repoName);
|
|
1613
|
+
continue;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// Scaffold the repo locally (creates dir, template, git init)
|
|
1617
|
+
const ok = scaffoldNewRepo(spec.localPath, spec.githubName, spec.template);
|
|
1618
|
+
if (!ok) {
|
|
1619
|
+
failed.push(repoName);
|
|
1620
|
+
continue;
|
|
1621
|
+
}
|
|
1622
|
+
scaffolded.push(repoName);
|
|
1623
|
+
|
|
1624
|
+
// Store metadata for later use in handleRepoConfirmation
|
|
1625
|
+
if (!existingPaths.has(spec.localPath)) {
|
|
1626
|
+
existingNewRepos.push(spec);
|
|
1627
|
+
existingPaths.add(spec.localPath);
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// Now treat as an existing repo for worktree creation
|
|
1631
|
+
existingRepoPaths.push(spec.localPath);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// Persist new_repos metadata
|
|
1635
|
+
if (newRepoSpecs.length > 0) {
|
|
1636
|
+
meta.new_repos = existingNewRepos;
|
|
1637
|
+
queries.updateFeatureMetadata(feature.id, meta);
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// Handle existing repos (+ newly scaffolded ones): create worktrees
|
|
1641
|
+
for (const repoPath of existingRepoPaths) {
|
|
1642
|
+
const repoAbsPath = path.join(projectRoot, repoPath);
|
|
1643
|
+
const repoName = path.basename(repoPath);
|
|
1644
|
+
const worktreeDest = path.join(reposBase, repoName);
|
|
1645
|
+
|
|
1646
|
+
// Skip if worktree already exists
|
|
1647
|
+
if (fs.existsSync(worktreeDest)) {
|
|
1648
|
+
if (!skipped.includes(repoName)) skipped.push(repoName);
|
|
1649
|
+
continue;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
// Validate the source repo exists
|
|
1653
|
+
if (!fs.existsSync(path.join(repoAbsPath, ".git"))) {
|
|
1654
|
+
console.warn(`[orchestrator] Repo not found: ${repoPath}`);
|
|
1655
|
+
if (!failed.includes(repoName)) failed.push(repoName);
|
|
1656
|
+
continue;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
try {
|
|
1660
|
+
// Create feature branch if it doesn't exist
|
|
1661
|
+
try {
|
|
1662
|
+
execSync(`git -C "${repoAbsPath}" branch "${featureBranch}" 2>/dev/null || true`, { stdio: "pipe" });
|
|
1663
|
+
} catch { /* branch may already exist */ }
|
|
1664
|
+
|
|
1665
|
+
// Create worktree
|
|
1666
|
+
execSync(
|
|
1667
|
+
`git -C "${repoAbsPath}" worktree add "${worktreeDest}" "${featureBranch}"`,
|
|
1668
|
+
{ stdio: "pipe", timeout: 30000 }
|
|
1669
|
+
);
|
|
1670
|
+
created.push(repoName);
|
|
1671
|
+
} catch (err) {
|
|
1672
|
+
// Try checking out existing branch
|
|
1673
|
+
try {
|
|
1674
|
+
execSync(
|
|
1675
|
+
`git -C "${repoAbsPath}" worktree add "${worktreeDest}" -b "${featureBranch}" 2>/dev/null || git -C "${repoAbsPath}" worktree add "${worktreeDest}" "${featureBranch}"`,
|
|
1676
|
+
{ stdio: "pipe", timeout: 30000 }
|
|
1677
|
+
);
|
|
1678
|
+
created.push(repoName);
|
|
1679
|
+
} catch (err2) {
|
|
1680
|
+
console.error(`[orchestrator] Failed to create worktree for ${repoPath}:`, err2.message);
|
|
1681
|
+
if (!failed.includes(repoName)) failed.push(repoName);
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// Update signal tree with repos dir
|
|
1687
|
+
const tree = this._signalTrees[feature.slug];
|
|
1688
|
+
if (tree) {
|
|
1689
|
+
tree.reposDir = reposBase;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// Post confirmation to feature channel
|
|
1693
|
+
const parts = [];
|
|
1694
|
+
if (scaffolded.length > 0) parts.push(`Scaffolded new: ${scaffolded.map(r => `\`${r}\``).join(", ")}`);
|
|
1695
|
+
if (created.length > 0) parts.push(`Created worktrees: ${created.map(r => `\`${r}\``).join(", ")}`);
|
|
1696
|
+
if (skipped.length > 0) parts.push(`Already exist: ${skipped.map(r => `\`${r}\``).join(", ")}`);
|
|
1697
|
+
if (failed.length > 0) parts.push(`Failed: ${failed.map(r => `\`${r}\``).join(", ")}`);
|
|
1698
|
+
|
|
1699
|
+
await this.adapter.postPipelineMessage(feature.id,
|
|
1700
|
+
`Repos pulled in for planning.\n${parts.join("\n")}\nWorktrees at: \`.features/${feature.slug}/repos/\``);
|
|
1701
|
+
|
|
1702
|
+
queries.insertEvent(feature.id, "system", "bridge",
|
|
1703
|
+
`Repos pulled: ${[...scaffolded, ...created].join(", ")}${failed.length ? ` | failed: ${failed.join(", ")}` : ""}`);
|
|
1704
|
+
|
|
1705
|
+
console.log(`[orchestrator] ${feature.slug}: scaffolded=${scaffolded.length} created=${created.length} skipped=${skipped.length} failed=${failed.length}`);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// ─── Feature Completion ──────────────────────────────────────────────
|
|
1709
|
+
|
|
1710
|
+
async _handleFeatureComplete(slug) {
|
|
1711
|
+
const feature = queries.getFeatureBySlug(slug);
|
|
1712
|
+
if (!feature) return;
|
|
1713
|
+
|
|
1714
|
+
await this.adapter.postFeatureComplete(feature.id);
|
|
1715
|
+
|
|
1716
|
+
db.transaction(() => {
|
|
1717
|
+
queries.updateFeaturePhase(feature.id, "complete");
|
|
1718
|
+
});
|
|
1719
|
+
|
|
1720
|
+
this.supervisor.killFeature(feature.id);
|
|
1721
|
+
this.fileIO.unwatchFeature(slug);
|
|
1722
|
+
queries.insertEvent(feature.id, "feature-complete", "bridge", "All gates passed");
|
|
1723
|
+
console.log(`[orchestrator] Feature ${slug} complete`);
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1727
|
+
// FILE I/O SIGNAL HANDLERS
|
|
1728
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1729
|
+
|
|
1730
|
+
_setupFileIOHandlers() {
|
|
1731
|
+
// Planning signals — now per-feature with { slug, role, filePath }
|
|
1732
|
+
this.fileIO.on("planning:response", async ({ slug, role, filePath }) => {
|
|
1733
|
+
try {
|
|
1734
|
+
const content = readSignal(filePath, { deleteAfter: true });
|
|
1735
|
+
if (!content) return;
|
|
1736
|
+
|
|
1737
|
+
const label = ROLE_LABELS[role] || role;
|
|
1738
|
+
const feature = queries.getFeatureBySlug(slug);
|
|
1739
|
+
if (!feature) return;
|
|
1740
|
+
|
|
1741
|
+
// Route through operator relay instead of posting directly
|
|
1742
|
+
await this._enqueueForOperatorRelay(feature, label, "planning-response", content);
|
|
1743
|
+
queries.insertEvent(feature.id, "agent-response", `agent:${role}`, content);
|
|
1744
|
+
} catch (err) {
|
|
1745
|
+
console.error("[orchestrator] Planning response error:", err.message);
|
|
1746
|
+
}
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1749
|
+
this.fileIO.on("planning:question", async ({ slug, role, filePath }) => {
|
|
1750
|
+
try {
|
|
1751
|
+
const content = readSignal(filePath, { deleteAfter: true });
|
|
1752
|
+
if (!content) return;
|
|
1753
|
+
|
|
1754
|
+
const label = ROLE_LABELS[role] || role;
|
|
1755
|
+
const feature = queries.getFeatureBySlug(slug);
|
|
1756
|
+
if (!feature) return;
|
|
1757
|
+
|
|
1758
|
+
// Route through operator relay instead of posting directly
|
|
1759
|
+
const questionMsg = `*${feature.slug} / ${feature.active_planning_role} phase*\n\n${content}`;
|
|
1760
|
+
await this._enqueueForOperatorRelay(feature, label, "planning-question", questionMsg);
|
|
1761
|
+
queries.insertEvent(feature.id, "question", `agent:${role}`, content);
|
|
1762
|
+
} catch (err) {
|
|
1763
|
+
console.error("[orchestrator] Planning question error:", err.message);
|
|
1764
|
+
}
|
|
1765
|
+
});
|
|
1766
|
+
|
|
1767
|
+
this.fileIO.on("planning:done", async ({ slug, role, filePath }) => {
|
|
1768
|
+
try {
|
|
1769
|
+
await this._handlePlanningDone(slug, role, filePath);
|
|
1770
|
+
} catch (err) {
|
|
1771
|
+
console.error("[orchestrator] Planning done error:", err.message);
|
|
1772
|
+
}
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
// Implementation signals — FL .agent-response routed through Operator relay
|
|
1776
|
+
this.fileIO.on("impl:response", async ({ slug, agent, filePath }) => {
|
|
1777
|
+
try {
|
|
1778
|
+
const content = readSignal(filePath, { deleteAfter: true });
|
|
1779
|
+
if (!content) return;
|
|
1780
|
+
const feature = queries.getFeatureBySlug(slug);
|
|
1781
|
+
if (!feature) return;
|
|
1782
|
+
await this._enqueueForOperatorRelay(feature, "Feature Lead", "fl-response", content);
|
|
1783
|
+
} catch (err) {
|
|
1784
|
+
console.error("[orchestrator] Impl response error:", err.message);
|
|
1785
|
+
}
|
|
1786
|
+
});
|
|
1787
|
+
|
|
1788
|
+
// impl:operatorResponse — SOLE path to user for impl-phase agent output
|
|
1789
|
+
this.fileIO.on("impl:operatorResponse", async ({ slug, filePath }) => {
|
|
1790
|
+
try {
|
|
1791
|
+
const content = readSignal(filePath, { deleteAfter: true });
|
|
1792
|
+
if (!content) return;
|
|
1793
|
+
const feature = queries.getFeatureBySlug(slug);
|
|
1794
|
+
if (!feature) return;
|
|
1795
|
+
|
|
1796
|
+
// Parse structured blocks from Operator output
|
|
1797
|
+
const parsed = parseOperatorResponse(content);
|
|
1798
|
+
|
|
1799
|
+
// Post the text content (with [DECISION] blocks stripped via parsed.plainText)
|
|
1800
|
+
const textContent = parsed.plainText || content;
|
|
1801
|
+
await this.adapter.postAgentResponse(feature.id, "Operator", textContent);
|
|
1802
|
+
|
|
1803
|
+
// Handle [DECISION] blocks — render as separate decision prompts.
|
|
1804
|
+
// Skip any that duplicate a deferred decision (already queued by _requestPhaseReview).
|
|
1805
|
+
for (const decision of parsed.decisions) {
|
|
1806
|
+
if (this._deferredDecisions[feature.id]) {
|
|
1807
|
+
const deferred = this._deferredDecisions[feature.id].decision;
|
|
1808
|
+
if (deferred.id === decision.id) continue;
|
|
1809
|
+
}
|
|
1810
|
+
const options = decision.options.map(o => ({
|
|
1811
|
+
id: o.id,
|
|
1812
|
+
label: o.label,
|
|
1813
|
+
style: o.style || "default",
|
|
1814
|
+
description: o.description || "",
|
|
1815
|
+
}));
|
|
1816
|
+
|
|
1817
|
+
// Start review session for gate decisions
|
|
1818
|
+
let reviewUrl = null;
|
|
1819
|
+
let qaUrl = null;
|
|
1820
|
+
if (this.reviewSessions && decision.type === "approval" && decision.id.startsWith("gate-")) {
|
|
1821
|
+
// Look for gate evidence HTML
|
|
1822
|
+
const evidencePath = path.join(feature.signal_dir, "feature-lead", ".gate-evidence.html");
|
|
1823
|
+
if (fs.existsSync(evidencePath)) {
|
|
1824
|
+
reviewUrl = await this.reviewSessions.startDocReview(
|
|
1825
|
+
decision.id, evidencePath, `Gate Evidence — ${decision.title}`, { featureId: feature.id }
|
|
1826
|
+
);
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// Read verification config from plan.yaml — start QA session if local-server
|
|
1830
|
+
const verification = this._readVerificationConfig(feature);
|
|
1831
|
+
if (verification && verification.type === "local-server" && verification.url) {
|
|
1832
|
+
if (verification.command) {
|
|
1833
|
+
// Start the dev server in the background
|
|
1834
|
+
try {
|
|
1835
|
+
const featureReposDir = path.join(process.env.HOME, "src/iriai/.features", feature.slug, "repos");
|
|
1836
|
+
const proc = cpSpawn("sh", ["-c", verification.command], {
|
|
1837
|
+
cwd: featureReposDir,
|
|
1838
|
+
stdio: "ignore",
|
|
1839
|
+
detached: true,
|
|
1840
|
+
});
|
|
1841
|
+
proc.unref();
|
|
1842
|
+
// Brief wait for server startup
|
|
1843
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
1844
|
+
} catch (err) {
|
|
1845
|
+
console.error(`[orchestrator] Failed to start dev server for gate review:`, err.message);
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
qaUrl = await this.reviewSessions.startQaSession(
|
|
1849
|
+
decision.id, verification.url, { featureId: feature.id }
|
|
1850
|
+
);
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
const decResult = await this.adapter.postDecision(feature.id, {
|
|
1855
|
+
id: decision.id,
|
|
1856
|
+
title: decision.title,
|
|
1857
|
+
context: decision.context,
|
|
1858
|
+
type: decision.type || "approval",
|
|
1859
|
+
options,
|
|
1860
|
+
reviewUrl,
|
|
1861
|
+
qaUrl,
|
|
1862
|
+
|
|
1863
|
+
});
|
|
1864
|
+
|
|
1865
|
+
// Store in DB
|
|
1866
|
+
queries.createDecision({
|
|
1867
|
+
featureId: feature.id,
|
|
1868
|
+
decisionId: decision.id,
|
|
1869
|
+
decisionType: decision.type || "approval",
|
|
1870
|
+
title: decision.title,
|
|
1871
|
+
contextText: decision.context || null,
|
|
1872
|
+
options,
|
|
1873
|
+
});
|
|
1874
|
+
|
|
1875
|
+
// Store adapter-specific ref. Channel resolved from feature record.
|
|
1876
|
+
const decChannel = feature.feature_channel || this.adapter.planningChannel;
|
|
1877
|
+
queries.updateDecisionSlack(feature.id, decision.id, {
|
|
1878
|
+
slackTs: decResult?.ref || null,
|
|
1879
|
+
slackChannel: decChannel || null,
|
|
1880
|
+
permalink: null,
|
|
1881
|
+
});
|
|
1882
|
+
|
|
1883
|
+
// Gate decisions: set gate_evidence_ts
|
|
1884
|
+
if (decision.type === "approval" && decision.id.startsWith("gate-")) {
|
|
1885
|
+
queries.updateFeatureGateEvidenceTs(feature.id, decResult?.ref || null);
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
// Handle [RESOLVE_DECISION] blocks — Operator resolved a decision conversationally
|
|
1890
|
+
for (const resolution of parsed.resolutions) {
|
|
1891
|
+
const channel = feature.feature_channel || feature.slug;
|
|
1892
|
+
const messageTs = `operator-${Date.now()}`;
|
|
1893
|
+
try {
|
|
1894
|
+
await this.handleDecisionClick(
|
|
1895
|
+
resolution.id, resolution.option, "operator",
|
|
1896
|
+
channel, messageTs, resolution.feedback || ""
|
|
1897
|
+
);
|
|
1898
|
+
} catch (err) {
|
|
1899
|
+
console.error(`[orchestrator] Decision resolution error (${resolution.id}):`, err.message);
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
// Handle [ROUTE:agent_key] blocks
|
|
1904
|
+
for (const route of parsed.routes) {
|
|
1905
|
+
const targetAgent = queries.getAgentByKey(route.agentKey);
|
|
1906
|
+
if (targetAgent) {
|
|
1907
|
+
ensureDir(targetAgent.signal_dir);
|
|
1908
|
+
writeSignal(path.join(targetAgent.signal_dir, SIGNAL.USER_MESSAGE), route.content);
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// Resolve any pending relay waiter
|
|
1913
|
+
this._resolveRelayWaiter(feature.id);
|
|
1914
|
+
} catch (err) {
|
|
1915
|
+
console.error("[orchestrator] Operator response error:", err.message);
|
|
1916
|
+
}
|
|
1917
|
+
});
|
|
1918
|
+
|
|
1919
|
+
this.fileIO.on("impl:featureComplete", async ({ slug }) => {
|
|
1920
|
+
await this._handleFeatureComplete(slug);
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
this.fileIO.on("impl:phaseDone", ({ slug, filePath }) => {
|
|
1924
|
+
// FL phase done — read signal and clean up
|
|
1925
|
+
try { fs.unlinkSync(filePath); } catch { /* ok */ }
|
|
1926
|
+
// The FL exit handler will spawn the next phase
|
|
1927
|
+
});
|
|
1928
|
+
|
|
1929
|
+
this.fileIO.on("impl:contextRefresh", ({ slug, filePath }) => {
|
|
1930
|
+
// FL context refresh — handled by supervisor exit handler
|
|
1931
|
+
try { fs.unlinkSync(filePath); } catch { /* ok */ }
|
|
1932
|
+
});
|
|
1933
|
+
|
|
1934
|
+
this.fileIO.on("impl:needsRestart", async ({ slug, dir, filePath }) => {
|
|
1935
|
+
try {
|
|
1936
|
+
const feature = queries.getFeatureBySlug(slug);
|
|
1937
|
+
if (!feature) return;
|
|
1938
|
+
// Find agent by signal dir
|
|
1939
|
+
const agents = queries.getAgentsByFeature(feature.id);
|
|
1940
|
+
const agent = agents.find(a => a.signal_dir === dir);
|
|
1941
|
+
const agentLabel = agent?.role_name || "Agent";
|
|
1942
|
+
|
|
1943
|
+
await this.adapter.postPipelineMessage(feature.id,
|
|
1944
|
+
`${agentLabel} restarting (context limit). Work preserved.`);
|
|
1945
|
+
queries.insertEvent(feature.id, "system", "bridge", `${agentLabel} needs restart`);
|
|
1946
|
+
|
|
1947
|
+
// For planning roles: kill the agent so exit handler triggers retry with handover
|
|
1948
|
+
if (agent && agent.agent_type === "planning-role") {
|
|
1949
|
+
try { fs.unlinkSync(filePath); } catch { /* ok */ }
|
|
1950
|
+
this.supervisor.kill(agent.agent_key);
|
|
1951
|
+
// The exit handler (_handlePlanningRoleExit) will detect no .done and call
|
|
1952
|
+
// scheduleRetry, which rebuilds the prompt with .handover file reference.
|
|
1953
|
+
}
|
|
1954
|
+
} catch (err) {
|
|
1955
|
+
console.error("[orchestrator] Needs restart error:", err.message);
|
|
1956
|
+
}
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1959
|
+
// Gate-ready: check if ALL teams are ready
|
|
1960
|
+
this.fileIO.on("impl:gateReady", ({ slug, teamNum }) => {
|
|
1961
|
+
const tree = this._signalTrees[slug];
|
|
1962
|
+
if (!tree) return;
|
|
1963
|
+
|
|
1964
|
+
const allReady = Object.keys(tree.teams).every(tn =>
|
|
1965
|
+
fs.existsSync(path.join(tree.teams[tn].orchestrator, SIGNAL.GATE_READY))
|
|
1966
|
+
);
|
|
1967
|
+
|
|
1968
|
+
if (allReady) {
|
|
1969
|
+
console.log(`[orchestrator] All teams gate-ready for ${slug}`);
|
|
1970
|
+
this._triggerFeatureLead(slug, "gate-ready");
|
|
1971
|
+
}
|
|
1972
|
+
});
|
|
1973
|
+
|
|
1974
|
+
this.fileIO.on("impl:question", ({ slug, teamNum }) => {
|
|
1975
|
+
const tree = this._signalTrees[slug];
|
|
1976
|
+
if (!tree) return;
|
|
1977
|
+
|
|
1978
|
+
const questionTeams = Object.keys(tree.teams).filter(tn =>
|
|
1979
|
+
fs.existsSync(path.join(tree.teams[tn].orchestrator, SIGNAL.QUESTION))
|
|
1980
|
+
);
|
|
1981
|
+
if (questionTeams.length > 0) {
|
|
1982
|
+
this._triggerFeatureLead(slug, "question", { questionTeams });
|
|
1983
|
+
}
|
|
1984
|
+
});
|
|
1985
|
+
|
|
1986
|
+
this.fileIO.on("impl:crashed", ({ slug, teamNum }) => {
|
|
1987
|
+
const tree = this._signalTrees[slug];
|
|
1988
|
+
if (!tree) return;
|
|
1989
|
+
|
|
1990
|
+
const crashedTeams = Object.keys(tree.teams).filter(tn =>
|
|
1991
|
+
fs.existsSync(path.join(tree.teams[tn].orchestrator, SIGNAL.CRASHED))
|
|
1992
|
+
);
|
|
1993
|
+
if (crashedTeams.length > 0) {
|
|
1994
|
+
this._triggerFeatureLead(slug, "crash", { crashedTeams });
|
|
1995
|
+
}
|
|
1996
|
+
});
|
|
1997
|
+
|
|
1998
|
+
// Wire .task signals to orchestrators and role agents
|
|
1999
|
+
this.fileIO.on("impl:orchTask", ({ slug, teamNum }) => {
|
|
2000
|
+
const feature = queries.getFeatureBySlug(slug);
|
|
2001
|
+
if (!feature) return;
|
|
2002
|
+
const orchKey = `orch-${slug}-${teamNum}`;
|
|
2003
|
+
const agent = queries.getAgentByKey(orchKey);
|
|
2004
|
+
if (!agent) return;
|
|
2005
|
+
this._handleOrchestratorTask(agent.id, feature, teamNum);
|
|
2006
|
+
});
|
|
2007
|
+
|
|
2008
|
+
this.fileIO.on("impl:task", ({ slug, teamNum, role }) => {
|
|
2009
|
+
const feature = queries.getFeatureBySlug(slug);
|
|
2010
|
+
if (!feature) return;
|
|
2011
|
+
const roleKey = `role-${slug}-${teamNum}-${role}`;
|
|
2012
|
+
const agent = queries.getAgentByKey(roleKey);
|
|
2013
|
+
if (!agent) return;
|
|
2014
|
+
this._handleRoleTask(agent.id, feature);
|
|
2015
|
+
});
|
|
2016
|
+
|
|
2017
|
+
// Operator user message
|
|
2018
|
+
this.fileIO.on("impl:userMessage", ({ slug, agent: agentName }) => {
|
|
2019
|
+
if (agentName !== "operator") return;
|
|
2020
|
+
const feature = queries.getFeatureBySlug(slug);
|
|
2021
|
+
if (!feature) return;
|
|
2022
|
+
this._handleOperatorMessage(feature);
|
|
2023
|
+
});
|
|
2024
|
+
|
|
2025
|
+
// Review agent done — route through Operator relay
|
|
2026
|
+
this.fileIO.on("impl:done", async ({ slug, agent, role, filePath }) => {
|
|
2027
|
+
if (!agent || !agent.startsWith("review-")) return;
|
|
2028
|
+
try {
|
|
2029
|
+
const feature = queries.getFeatureBySlug(slug);
|
|
2030
|
+
if (!feature) return;
|
|
2031
|
+
|
|
2032
|
+
const outputPath = path.join(path.dirname(filePath), SIGNAL.OUTPUT);
|
|
2033
|
+
const rawOutput = readSignal(outputPath);
|
|
2034
|
+
if (!rawOutput) {
|
|
2035
|
+
await this.adapter.postMessage(feature.id, `*[${role}]* Review complete (no output).`);
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
queries.insertEvent(feature.id, "agent-response", `agent:${role}`, rawOutput);
|
|
2040
|
+
await this._enqueueForOperatorRelay(feature, role, "review-completion", rawOutput);
|
|
2041
|
+
} catch (err) {
|
|
2042
|
+
console.error(`[orchestrator] Review done error for ${role}:`, err.message);
|
|
2043
|
+
}
|
|
2044
|
+
});
|
|
2045
|
+
|
|
2046
|
+
// Operator .needs-repos — create worktrees for requested repos
|
|
2047
|
+
this.fileIO.on("impl:needsRepos", async ({ slug, filePath }) => {
|
|
2048
|
+
try {
|
|
2049
|
+
const content = readSignal(filePath, { deleteAfter: true });
|
|
2050
|
+
if (!content) return;
|
|
2051
|
+
const feature = queries.getFeatureBySlug(slug);
|
|
2052
|
+
if (!feature) return;
|
|
2053
|
+
|
|
2054
|
+
await this._handleNeedsRepos(feature, content);
|
|
2055
|
+
} catch (err) {
|
|
2056
|
+
console.error("[orchestrator] Needs repos error:", err.message);
|
|
2057
|
+
}
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
_triggerFeatureLead(slug, trigger, opts = {}) {
|
|
2062
|
+
const feature = queries.getFeatureBySlug(slug);
|
|
2063
|
+
if (!feature) return;
|
|
2064
|
+
const flKey = `fl-${slug}`;
|
|
2065
|
+
const flAgent = queries.getAgentByKey(flKey);
|
|
2066
|
+
if (!flAgent) return;
|
|
2067
|
+
|
|
2068
|
+
const tree = this._signalTrees[slug];
|
|
2069
|
+
if (!tree) return;
|
|
2070
|
+
|
|
2071
|
+
// If FL is already running, send trigger as a .user-message instead of killing it
|
|
2072
|
+
if (this.supervisor.isRunning(flKey)) {
|
|
2073
|
+
const msg = trigger === "gate-ready"
|
|
2074
|
+
? "TRIGGER: All teams have signaled gate-ready. Begin gate review."
|
|
2075
|
+
: trigger === "question"
|
|
2076
|
+
? `TRIGGER: Teams ${(opts.questionTeams || []).join(", ")} have questions needing resolution.`
|
|
2077
|
+
: trigger === "crash"
|
|
2078
|
+
? `TRIGGER: Teams ${(opts.crashedTeams || []).join(", ")} have crashed orchestrators. Handle recovery.`
|
|
2079
|
+
: `TRIGGER: ${trigger}`;
|
|
2080
|
+
writeSignal(path.join(tree.featureLead, SIGNAL.USER_MESSAGE), msg);
|
|
2081
|
+
return;
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
// FL not running — spawn in trigger mode
|
|
2085
|
+
this._spawnFeatureLead(flAgent.id, feature, tree, "trigger", {
|
|
2086
|
+
trigger,
|
|
2087
|
+
...opts,
|
|
2088
|
+
});
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
_handleOrchestratorTask(agentId, feature, teamNum) {
|
|
2092
|
+
const agent = queries.getAgentById(agentId);
|
|
2093
|
+
if (!agent) return;
|
|
2094
|
+
const tree = this._signalTrees[feature.slug];
|
|
2095
|
+
if (!tree) return;
|
|
2096
|
+
const team = tree.teams[teamNum];
|
|
2097
|
+
if (!team) return;
|
|
2098
|
+
|
|
2099
|
+
// Read and consume .task
|
|
2100
|
+
const taskFile = path.join(team.orchestrator, SIGNAL.TASK);
|
|
2101
|
+
const task = readSignal(taskFile, { deleteAfter: true });
|
|
2102
|
+
if (!task) return;
|
|
2103
|
+
|
|
2104
|
+
// Clean previous signals
|
|
2105
|
+
for (const sig of [SIGNAL.DONE, SIGNAL.GATE_READY, SIGNAL.CRASHED, SIGNAL.GATE_APPROVED, SIGNAL.QUESTION, SIGNAL.ANSWER]) {
|
|
2106
|
+
try { fs.unlinkSync(path.join(team.orchestrator, sig)); } catch { /* ok */ }
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
// Resolve team-specific repos directory for branch isolation
|
|
2110
|
+
const teamReposDir = path.join(process.env.HOME, "src/iriai/.features", feature.slug, "teams", String(teamNum), "repos");
|
|
2111
|
+
const prompt = buildOrchestratorPrompt({
|
|
2112
|
+
teamDir: team.dir,
|
|
2113
|
+
orchDir: team.orchestrator,
|
|
2114
|
+
task,
|
|
2115
|
+
teamReposDir: fs.existsSync(teamReposDir) ? teamReposDir : null,
|
|
2116
|
+
});
|
|
2117
|
+
|
|
2118
|
+
this.supervisor.spawn(agentId, prompt);
|
|
2119
|
+
queries.insertEvent(feature.id, "agent-started", "bridge", `Team ${teamNum} orchestrator dispatched`);
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
_handleRoleTask(agentId, feature) {
|
|
2123
|
+
const agent = queries.getAgentById(agentId);
|
|
2124
|
+
if (!agent) return;
|
|
2125
|
+
|
|
2126
|
+
// Atomic rename: .task → .active-task
|
|
2127
|
+
const taskFile = path.join(agent.signal_dir, SIGNAL.TASK);
|
|
2128
|
+
const activeFile = path.join(agent.signal_dir, SIGNAL.ACTIVE_TASK);
|
|
2129
|
+
let task;
|
|
2130
|
+
try {
|
|
2131
|
+
fs.renameSync(taskFile, activeFile);
|
|
2132
|
+
task = fs.readFileSync(activeFile, "utf-8").trim();
|
|
2133
|
+
} catch {
|
|
2134
|
+
task = readSignal(taskFile, { deleteAfter: true });
|
|
2135
|
+
}
|
|
2136
|
+
if (!task) return;
|
|
2137
|
+
|
|
2138
|
+
// Clean previous signals
|
|
2139
|
+
for (const sig of [SIGNAL.DONE, SIGNAL.CRASHED]) {
|
|
2140
|
+
try { fs.unlinkSync(path.join(agent.signal_dir, sig)); } catch { /* ok */ }
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
const prompt = buildRolePrompt({
|
|
2144
|
+
role: agent.role_name,
|
|
2145
|
+
signalDir: agent.signal_dir,
|
|
2146
|
+
task,
|
|
2147
|
+
});
|
|
2148
|
+
|
|
2149
|
+
this.supervisor.spawn(agentId, prompt);
|
|
2150
|
+
queries.insertEvent(feature.id, "agent-started", "bridge", `Role ${agent.role_name} (team ${agent.team_num}) dispatched`);
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
async _handleOperatorMessage(feature) {
|
|
2154
|
+
const opKey = `op-${feature.slug}`;
|
|
2155
|
+
const opAgent = queries.getAgentByKey(opKey);
|
|
2156
|
+
if (!opAgent) return;
|
|
2157
|
+
|
|
2158
|
+
const tree = this._signalTrees[feature.slug];
|
|
2159
|
+
if (!tree?.operator) return;
|
|
2160
|
+
|
|
2161
|
+
// Read user message
|
|
2162
|
+
const msgPath = path.join(tree.operator, SIGNAL.USER_MESSAGE);
|
|
2163
|
+
const userMessage = readSignal(msgPath, { deleteAfter: true });
|
|
2164
|
+
if (!userMessage) return;
|
|
2165
|
+
|
|
2166
|
+
// Invoke ephemeral operator with planning-phase context
|
|
2167
|
+
const planDir = tree.plansDir || path.join(tree.featureDir, "plans");
|
|
2168
|
+
try {
|
|
2169
|
+
await invokeOperator({
|
|
2170
|
+
feature,
|
|
2171
|
+
operatorDir: tree.operator,
|
|
2172
|
+
flDir: tree.featureLead,
|
|
2173
|
+
featureDir: tree.featureDir,
|
|
2174
|
+
userMessage,
|
|
2175
|
+
supervisor: this.supervisor,
|
|
2176
|
+
agentId: opAgent.id,
|
|
2177
|
+
planDir,
|
|
2178
|
+
activePlanningRole: feature.active_planning_role,
|
|
2179
|
+
});
|
|
2180
|
+
} catch (err) {
|
|
2181
|
+
console.error("[orchestrator] Operator invocation error:", err.message);
|
|
2182
|
+
// Fallback: write raw message to active agent
|
|
2183
|
+
if (tree.featureLead) {
|
|
2184
|
+
writeSignal(path.join(tree.featureLead, SIGNAL.USER_MESSAGE), userMessage);
|
|
2185
|
+
} else if (feature.active_planning_role && tree.planning?.[feature.active_planning_role]) {
|
|
2186
|
+
writeSignal(path.join(tree.planning[feature.active_planning_role], SIGNAL.USER_MESSAGE), userMessage);
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
/**
|
|
2192
|
+
* Handle operator exit — retry on failure (e.g. "Prompt is too long").
|
|
2193
|
+
* On success (exit 0), reset to idle.
|
|
2194
|
+
*/
|
|
2195
|
+
_handleOperatorExit(agentId, feature, exitCode, elapsed) {
|
|
2196
|
+
if (exitCode === 0) {
|
|
2197
|
+
queries.updateAgentStatus(agentId, "idle");
|
|
2198
|
+
queries.resetAgentRetry(agentId);
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
// Non-zero exit — schedule retry via supervisor (respects max_retries)
|
|
2203
|
+
const tree = this._signalTrees[feature.slug];
|
|
2204
|
+
if (!tree) {
|
|
2205
|
+
queries.updateAgentStatus(agentId, "crashed");
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
console.log(`[orchestrator] Operator exited with code ${exitCode} after ${elapsed}ms — scheduling retry`);
|
|
2210
|
+
|
|
2211
|
+
const retried = this.supervisor.scheduleRetry(agentId, () => {
|
|
2212
|
+
// On retry, assembleHistory will re-summarize with a tighter budget
|
|
2213
|
+
return { prompt: "Re-read your CLAUDE.md and check for pending relay queue items or user messages. Write a status update to .agent-response if there's nothing to relay.", continue: false };
|
|
2214
|
+
});
|
|
2215
|
+
|
|
2216
|
+
if (!retried) {
|
|
2217
|
+
queries.updateAgentStatus(agentId, "idle");
|
|
2218
|
+
console.error(`[orchestrator] Operator for ${feature.slug} exhausted retries — resetting to idle`);
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2223
|
+
// SUPERVISOR EVENT HANDLERS
|
|
2224
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2225
|
+
|
|
2226
|
+
_setupSupervisorHandlers() {
|
|
2227
|
+
this.supervisor.on("agent:exit", (info) => {
|
|
2228
|
+
this._handleAgentExit(info);
|
|
2229
|
+
});
|
|
2230
|
+
|
|
2231
|
+
this.supervisor.on("agent:crashed", async (info) => {
|
|
2232
|
+
const feature = queries.getFeatureById(info.featureId);
|
|
2233
|
+
if (!feature) return;
|
|
2234
|
+
await this.adapter.postPipelineMessage(feature.id,
|
|
2235
|
+
`Agent ${info.agentKey} crashed after ${info.retryCount} retries.`);
|
|
2236
|
+
queries.insertEvent(info.featureId, "agent-crashed", "bridge",
|
|
2237
|
+
`${info.agentKey} crashed after ${info.retryCount} retries`);
|
|
2238
|
+
});
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
_handleAgentExit({ agentId, agentKey, agentType, featureId, exitCode, elapsed, signalDir, roleName, teamNum }) {
|
|
2242
|
+
const feature = queries.getFeatureById(featureId);
|
|
2243
|
+
if (!feature) return;
|
|
2244
|
+
|
|
2245
|
+
switch (agentType) {
|
|
2246
|
+
case "planning-role":
|
|
2247
|
+
this._handlePlanningRoleExit(agentId, feature, signalDir, roleName);
|
|
2248
|
+
break;
|
|
2249
|
+
case "feature-lead":
|
|
2250
|
+
this._handleFeatureLeadExit(agentId, feature, signalDir, elapsed);
|
|
2251
|
+
break;
|
|
2252
|
+
case "team-orchestrator":
|
|
2253
|
+
this._handleOrchestratorExit(agentId, feature, signalDir, teamNum, elapsed);
|
|
2254
|
+
break;
|
|
2255
|
+
case "role-agent":
|
|
2256
|
+
case "review-agent":
|
|
2257
|
+
this._handleRoleExit(agentId, feature, signalDir, roleName, elapsed);
|
|
2258
|
+
break;
|
|
2259
|
+
case "operator":
|
|
2260
|
+
this._handleOperatorExit(agentId, feature, exitCode, elapsed);
|
|
2261
|
+
break;
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
_handlePlanningRoleExit(agentId, feature, signalDir, roleName) {
|
|
2266
|
+
const doneFile = path.join(signalDir, SIGNAL.DONE);
|
|
2267
|
+
if (fs.existsSync(doneFile)) {
|
|
2268
|
+
queries.updateAgentStatus(agentId, "done");
|
|
2269
|
+
this._handlePlanningDone(feature.slug, roleName, doneFile);
|
|
2270
|
+
return;
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
// Check if the file watcher already processed .done (it deletes the file).
|
|
2274
|
+
// If review gate is posted or agent already marked done, don't retry.
|
|
2275
|
+
const meta = queries.getFeatureMetadata(feature.id);
|
|
2276
|
+
const agent = queries.getAgentById(agentId);
|
|
2277
|
+
if (meta.awaiting_phase_review && meta.phase_review_role === roleName) {
|
|
2278
|
+
console.log(`[orchestrator] ${roleName} exit: review gate already posted, skipping retry`);
|
|
2279
|
+
queries.updateAgentStatus(agentId, "done");
|
|
2280
|
+
return;
|
|
2281
|
+
}
|
|
2282
|
+
if (agent && agent.status === "done") {
|
|
2283
|
+
console.log(`[orchestrator] ${roleName} exit: already marked done, skipping retry`);
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2286
|
+
// Also skip if phase has already advanced past this role
|
|
2287
|
+
if (feature.active_planning_role && feature.active_planning_role !== roleName) {
|
|
2288
|
+
console.log(`[orchestrator] ${roleName} exit: phase already advanced to ${feature.active_planning_role}, skipping retry`);
|
|
2289
|
+
queries.updateAgentStatus(agentId, "done");
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
{
|
|
2294
|
+
// Retry with file-based handover reference (never embed content inline)
|
|
2295
|
+
const featureDir = feature.signal_dir;
|
|
2296
|
+
const planDir = path.join(featureDir, "plans");
|
|
2297
|
+
const reposDir = path.join(process.env.HOME, "src/iriai/.features", feature.slug, "repos");
|
|
2298
|
+
|
|
2299
|
+
// Check if plan artifacts are large enough to warrant summarization
|
|
2300
|
+
const artifactStats = this._getPlanArtifactStats(planDir);
|
|
2301
|
+
const needsSummary = artifactStats.totalKB > ARTIFACT_SUMMARY_THRESHOLD_KB;
|
|
2302
|
+
|
|
2303
|
+
if (needsSummary) {
|
|
2304
|
+
// Run summarizer first, then retry the planning role on completion
|
|
2305
|
+
console.log(`[orchestrator] ${roleName}: plan artifacts are ${artifactStats.totalKB} KB — running summarizer before retry`);
|
|
2306
|
+
this._runArtifactSummarizer(feature, planDir, signalDir, artifactStats, () => {
|
|
2307
|
+
this._schedulePlanningRetry(agentId, feature, signalDir, planDir, featureDir, reposDir, roleName);
|
|
2308
|
+
});
|
|
2309
|
+
} else {
|
|
2310
|
+
this._schedulePlanningRetry(agentId, feature, signalDir, planDir, featureDir, reposDir, roleName);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
/**
|
|
2316
|
+
* Ensure a mockup review session exists and inject its URL into design-decisions.md.
|
|
2317
|
+
* Used by both _requestPhaseReview (fresh) and repostPendingPhaseReview (recovery).
|
|
2318
|
+
*/
|
|
2319
|
+
async _ensureMockupSession(planDir, mockupDecisionId, featureId) {
|
|
2320
|
+
const mockupPath = path.join(planDir, "mockup.html");
|
|
2321
|
+
if (!fs.existsSync(mockupPath)) return;
|
|
2322
|
+
|
|
2323
|
+
// Try restore first, then start fresh
|
|
2324
|
+
let mockupUrl = await this.reviewSessions.restoreSession(mockupDecisionId);
|
|
2325
|
+
if (!mockupUrl) {
|
|
2326
|
+
mockupUrl = await this.reviewSessions.startMockupReview(
|
|
2327
|
+
mockupDecisionId, mockupPath, { featureId }
|
|
2328
|
+
);
|
|
2329
|
+
}
|
|
2330
|
+
if (!mockupUrl) return;
|
|
2331
|
+
|
|
2332
|
+
// Inject mockup URL into design-decisions.md (strip stale ones first)
|
|
2333
|
+
const designDocPath = findArtifact("design-decisions", planDir);
|
|
2334
|
+
if (!designDocPath) return;
|
|
2335
|
+
|
|
2336
|
+
let content = fs.readFileSync(designDocPath, "utf-8");
|
|
2337
|
+
content = content.replace(/\n+---\n+## Interactive Mockup\n+\*\*\[View UI Mockup in Browser\]\(http[^)]*\)\*\*\n+[^\n]*\n*/g, "");
|
|
2338
|
+
|
|
2339
|
+
const section = `\n\n---\n\n## Interactive Mockup\n\n**[View UI Mockup in Browser](${mockupUrl})**\n\nOpen the link above to see the HTML/CSS mockup with annotation tools.\n`;
|
|
2340
|
+
const overviewEnd = content.indexOf("\n---", content.indexOf("## Overview"));
|
|
2341
|
+
if (overviewEnd > 0) {
|
|
2342
|
+
fs.writeFileSync(designDocPath, content.slice(0, overviewEnd) + section + content.slice(overviewEnd), "utf-8");
|
|
2343
|
+
} else {
|
|
2344
|
+
const firstHeadingEnd = content.indexOf("\n", content.indexOf("# "));
|
|
2345
|
+
if (firstHeadingEnd > 0) {
|
|
2346
|
+
fs.writeFileSync(designDocPath, content.slice(0, firstHeadingEnd + 1) + section + content.slice(firstHeadingEnd + 1), "utf-8");
|
|
2347
|
+
} else {
|
|
2348
|
+
fs.writeFileSync(designDocPath, section + "\n" + content, "utf-8");
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
/**
|
|
2354
|
+
* Schedule a planning role retry (extracted for use with/without summarizer).
|
|
2355
|
+
*/
|
|
2356
|
+
_schedulePlanningRetry(agentId, feature, signalDir, planDir, featureDir, reposDir, roleName) {
|
|
2357
|
+
const summaryPath = path.join(planDir, ARTIFACT_SUMMARY_FILE);
|
|
2358
|
+
const hasSummary = fs.existsSync(summaryPath);
|
|
2359
|
+
|
|
2360
|
+
const retried = this.supervisor.scheduleRetry(agentId, (agent) => {
|
|
2361
|
+
const hasHandover = fs.existsSync(path.join(signalDir, SIGNAL.HANDOVER));
|
|
2362
|
+
|
|
2363
|
+
const taskHeader = [
|
|
2364
|
+
`SLACK_MODE=true`,
|
|
2365
|
+
`FEATURE_SLUG=${feature.slug}`,
|
|
2366
|
+
`SIGNAL_DIR=${signalDir}`,
|
|
2367
|
+
`PLAN_DIR=${planDir}`,
|
|
2368
|
+
`FEATURE_DIR=${featureDir}`,
|
|
2369
|
+
`REPOS_DIR=${reposDir}`,
|
|
2370
|
+
].join("\n");
|
|
2371
|
+
|
|
2372
|
+
let handoverRef;
|
|
2373
|
+
if (hasHandover) {
|
|
2374
|
+
handoverRef = `\nCONTINUATION: Read ${signalDir}/.handover for your previous session's context. Resume from where it left off.\n`;
|
|
2375
|
+
} else {
|
|
2376
|
+
handoverRef = `\nRETRY ${agent.retry_count}: Previous session crashed. Check git status/diff for completed work. Do NOT redo completed work.\n`;
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
// If summarizer produced a summary, tell the agent to use it instead of re-reading full artifacts
|
|
2380
|
+
if (hasSummary) {
|
|
2381
|
+
handoverRef += `
|
|
2382
|
+
ARTIFACT SUMMARY AVAILABLE: A compressed summary of your plan artifacts exists at:
|
|
2383
|
+
${summaryPath}
|
|
2384
|
+
|
|
2385
|
+
Read this summary FIRST to restore context efficiently. It preserves component hierarchies,
|
|
2386
|
+
state tables, test IDs, and flow names while compressing prose and visual specs.
|
|
2387
|
+
Only read full artifact files (design-decisions.md, PRD, mockup.html) when you need to
|
|
2388
|
+
modify a SPECIFIC section — never re-read them in full.\n`;
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
const prompt = buildPlanningRolePrompt({
|
|
2392
|
+
task: taskHeader + handoverRef,
|
|
2393
|
+
signalDir,
|
|
2394
|
+
featureSlug: feature.slug,
|
|
2395
|
+
});
|
|
2396
|
+
|
|
2397
|
+
return { prompt, continue: !hasHandover };
|
|
2398
|
+
});
|
|
2399
|
+
if (!retried) {
|
|
2400
|
+
queries.insertEvent(feature.id, "agent-crashed", "bridge", `Planning role ${roleName} crashed`);
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
/**
|
|
2405
|
+
* Get size stats for plan directory artifacts.
|
|
2406
|
+
*/
|
|
2407
|
+
_getPlanArtifactStats(planDir) {
|
|
2408
|
+
const result = { totalKB: 0, artifacts: [] };
|
|
2409
|
+
if (!fs.existsSync(planDir)) return result;
|
|
2410
|
+
|
|
2411
|
+
const ARTIFACT_EXTENSIONS = new Set([".md", ".html", ".json", ".yaml", ".yml"]);
|
|
2412
|
+
try {
|
|
2413
|
+
for (const name of fs.readdirSync(planDir)) {
|
|
2414
|
+
// Skip the summary file itself and hidden files
|
|
2415
|
+
if (name === ARTIFACT_SUMMARY_FILE || name.startsWith(".")) continue;
|
|
2416
|
+
const ext = path.extname(name).toLowerCase();
|
|
2417
|
+
if (!ARTIFACT_EXTENSIONS.has(ext)) continue;
|
|
2418
|
+
|
|
2419
|
+
const filePath = path.join(planDir, name);
|
|
2420
|
+
const stat = fs.statSync(filePath);
|
|
2421
|
+
if (!stat.isFile()) continue;
|
|
2422
|
+
|
|
2423
|
+
const sizeKB = Math.round(stat.size / 1024);
|
|
2424
|
+
// Estimate line count for large files to avoid reading them into memory
|
|
2425
|
+
let lines;
|
|
2426
|
+
if (stat.size > 100 * 1024) {
|
|
2427
|
+
lines = Math.round(stat.size / 80); // ~80 bytes/line estimate
|
|
2428
|
+
} else {
|
|
2429
|
+
lines = fs.readFileSync(filePath, "utf-8").split("\n").length;
|
|
2430
|
+
}
|
|
2431
|
+
result.artifacts.push({ name, sizeKB, lines, path: filePath });
|
|
2432
|
+
result.totalKB += sizeKB;
|
|
2433
|
+
}
|
|
2434
|
+
} catch { /* ok — dir might be gone */ }
|
|
2435
|
+
|
|
2436
|
+
return result;
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
/**
|
|
2440
|
+
* Spawn a fast summarizer agent (sonnet) to compress plan artifacts before restart.
|
|
2441
|
+
* Calls onComplete when done (whether success or failure — we retry either way).
|
|
2442
|
+
*/
|
|
2443
|
+
_runArtifactSummarizer(feature, planDir, roleSignalDir, artifactStats, onComplete) {
|
|
2444
|
+
const summarizerDir = path.join(roleSignalDir, ".summarizer");
|
|
2445
|
+
ensureDir(summarizerDir);
|
|
2446
|
+
|
|
2447
|
+
const outputPath = path.join(planDir, ARTIFACT_SUMMARY_FILE);
|
|
2448
|
+
|
|
2449
|
+
const prompt = buildArtifactSummarizerPrompt({
|
|
2450
|
+
planDir,
|
|
2451
|
+
outputPath,
|
|
2452
|
+
artifacts: artifactStats.artifacts,
|
|
2453
|
+
});
|
|
2454
|
+
|
|
2455
|
+
// Spawn directly via AgentProcess (no DB record needed — this is ephemeral)
|
|
2456
|
+
const proc = new AgentProcess({
|
|
2457
|
+
key: `summarizer-${feature.slug}`,
|
|
2458
|
+
cwd: planDir,
|
|
2459
|
+
extraEnv: {},
|
|
2460
|
+
signalDir: summarizerDir,
|
|
2461
|
+
});
|
|
2462
|
+
|
|
2463
|
+
proc.spawnClaude(prompt, { model: SUMMARIZER_MODEL });
|
|
2464
|
+
queries.insertEvent(feature.id, "summarizer-started", "bridge",
|
|
2465
|
+
`Artifact summarizer dispatched (${artifactStats.totalKB} KB across ${artifactStats.artifacts.length} files)`);
|
|
2466
|
+
|
|
2467
|
+
// Timeout fallback — don't let a stuck summarizer block the retry forever
|
|
2468
|
+
const timer = setTimeout(() => {
|
|
2469
|
+
console.log(`[orchestrator] Summarizer timed out for ${feature.slug} — proceeding with retry`);
|
|
2470
|
+
proc.kill();
|
|
2471
|
+
onComplete();
|
|
2472
|
+
}, SUMMARIZER_TIMEOUT_MS);
|
|
2473
|
+
|
|
2474
|
+
proc.on("exit", ({ exitCode }) => {
|
|
2475
|
+
clearTimeout(timer);
|
|
2476
|
+
|
|
2477
|
+
if (exitCode === 0 && fs.existsSync(outputPath)) {
|
|
2478
|
+
const summarySize = Math.round(fs.statSync(outputPath).size / 1024);
|
|
2479
|
+
console.log(`[orchestrator] Summarizer complete for ${feature.slug}: ${summarySize} KB summary (from ${artifactStats.totalKB} KB artifacts)`);
|
|
2480
|
+
queries.insertEvent(feature.id, "summarizer-done", "bridge",
|
|
2481
|
+
`Artifact summary written: ${summarySize} KB (compressed from ${artifactStats.totalKB} KB)`);
|
|
2482
|
+
} else {
|
|
2483
|
+
console.log(`[orchestrator] Summarizer failed for ${feature.slug} (exit ${exitCode}) — proceeding without summary`);
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
// Clean up ephemeral summarizer dir
|
|
2487
|
+
try { fs.rmSync(summarizerDir, { recursive: true, force: true }); } catch { /* ok */ }
|
|
2488
|
+
|
|
2489
|
+
onComplete();
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
_handleFeatureLeadExit(agentId, feature, signalDir, elapsed) {
|
|
2494
|
+
const tree = this._signalTrees[feature.slug];
|
|
2495
|
+
if (!tree) return;
|
|
2496
|
+
|
|
2497
|
+
// Check signals in priority order
|
|
2498
|
+
const completeFile = path.join(signalDir, SIGNAL.FEATURE_COMPLETE);
|
|
2499
|
+
if (fs.existsSync(completeFile)) {
|
|
2500
|
+
queries.updateAgentStatus(agentId, "done");
|
|
2501
|
+
this._handleFeatureComplete(feature.slug);
|
|
2502
|
+
return;
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
const refreshFile = path.join(signalDir, SIGNAL.CONTEXT_REFRESH);
|
|
2506
|
+
if (fs.existsSync(refreshFile)) {
|
|
2507
|
+
try { fs.unlinkSync(refreshFile); } catch { /* ok */ }
|
|
2508
|
+
// Read handover if available
|
|
2509
|
+
const handoverFile = path.join(signalDir, SIGNAL.HANDOVER);
|
|
2510
|
+
const handoverContent = readSignal(handoverFile, { deleteAfter: true });
|
|
2511
|
+
queries.resetAgentRetry(agentId);
|
|
2512
|
+
this._spawnFeatureLead(agentId, feature, tree, "refresh", { handoverContent });
|
|
2513
|
+
return;
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
const phaseDoneFile = path.join(signalDir, SIGNAL.PHASE_DONE);
|
|
2517
|
+
if (fs.existsSync(phaseDoneFile)) {
|
|
2518
|
+
try { fs.unlinkSync(phaseDoneFile); } catch { /* ok */ }
|
|
2519
|
+
// Phase complete — spawn refresh for next phase
|
|
2520
|
+
queries.resetAgentRetry(agentId);
|
|
2521
|
+
this._spawnFeatureLead(agentId, feature, tree, "refresh");
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
const needsRestartFile = path.join(signalDir, SIGNAL.NEEDS_RESTART);
|
|
2526
|
+
if (fs.existsSync(needsRestartFile)) {
|
|
2527
|
+
try { fs.unlinkSync(needsRestartFile); } catch { /* ok */ }
|
|
2528
|
+
const handoverContent = readSignal(path.join(signalDir, SIGNAL.HANDOVER), { deleteAfter: true });
|
|
2529
|
+
queries.resetAgentRetry(agentId);
|
|
2530
|
+
this._spawnFeatureLead(agentId, feature, tree, "refresh", { handoverContent });
|
|
2531
|
+
return;
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
// Guard: file watcher may have already processed signals and deleted them.
|
|
2535
|
+
// If agent is already marked done, don't retry.
|
|
2536
|
+
const agentState = queries.getAgentById(agentId);
|
|
2537
|
+
if (agentState && agentState.status === "done") {
|
|
2538
|
+
console.log(`[orchestrator] FL exit: already marked done, skipping retry`);
|
|
2539
|
+
return;
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
// Auto-refresh if ran long enough (context exhaustion)
|
|
2543
|
+
if (elapsed >= FL_CONTEXT_EXHAUST_MS) {
|
|
2544
|
+
queries.resetAgentRetry(agentId);
|
|
2545
|
+
this._spawnFeatureLead(agentId, feature, tree, "refresh");
|
|
2546
|
+
return;
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
// Otherwise crash — try retry with --continue (conversation is intact, just process died)
|
|
2550
|
+
const retried = this.supervisor.scheduleRetry(agentId, () => {
|
|
2551
|
+
const prompt = buildFeatureLeadRefreshPrompt({
|
|
2552
|
+
featureName: feature.slug,
|
|
2553
|
+
numTeams: Object.keys(tree.teams).length || feature.num_teams,
|
|
2554
|
+
teamSignalBase: path.join(tree.featureDir, "teams"),
|
|
2555
|
+
planReadInstruction: this._resolvePlanInstruction(tree.featureLead, tree.featureDir),
|
|
2556
|
+
featureLeadDir: tree.featureLead,
|
|
2557
|
+
featureReviewDir: path.join(tree.featureDir, "feature-review"),
|
|
2558
|
+
dashboardLog: path.join(tree.featureDir, ".dashboard-log"),
|
|
2559
|
+
gateEvidenceTs: feature.gate_evidence_ts,
|
|
2560
|
+
featureDir: tree.featureDir,
|
|
2561
|
+
});
|
|
2562
|
+
return { prompt, continue: true };
|
|
2563
|
+
});
|
|
2564
|
+
|
|
2565
|
+
if (!retried) {
|
|
2566
|
+
// Max retries exhausted — stop and notify
|
|
2567
|
+
queries.updateAgentStatus(agentId, "idle");
|
|
2568
|
+
this.adapter.postPipelineMessage(feature.id,
|
|
2569
|
+
`Feature Lead exhausted all retries. Waiting for operator or user intervention. Use "restart FL" to retry.`).catch(() => {});
|
|
2570
|
+
queries.insertEvent(feature.id, "agent-crashed", "bridge", "Feature Lead exhausted max retries, stopped.");
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
_handleOrchestratorExit(agentId, feature, signalDir, teamNum, elapsed) {
|
|
2575
|
+
const gateReadyFile = path.join(signalDir, SIGNAL.GATE_READY);
|
|
2576
|
+
const doneFile = path.join(signalDir, SIGNAL.DONE);
|
|
2577
|
+
|
|
2578
|
+
if (fs.existsSync(gateReadyFile) || fs.existsSync(doneFile)) {
|
|
2579
|
+
queries.updateAgentStatus(agentId, "done");
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
const needsRestartFile = path.join(signalDir, SIGNAL.NEEDS_RESTART);
|
|
2584
|
+
if (fs.existsSync(needsRestartFile)) {
|
|
2585
|
+
try { fs.unlinkSync(needsRestartFile); } catch { /* ok */ }
|
|
2586
|
+
// Don't count as crash
|
|
2587
|
+
queries.updateAgentStatus(agentId, "idle");
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
// Guard: file watcher may have already processed signals and deleted them.
|
|
2592
|
+
const agentState = queries.getAgentById(agentId);
|
|
2593
|
+
if (agentState && agentState.status === "done") {
|
|
2594
|
+
console.log(`[orchestrator] Orch-${teamNum} exit: already marked done, skipping retry`);
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
// Retry
|
|
2599
|
+
const tree = this._signalTrees[feature.slug];
|
|
2600
|
+
const team = tree?.teams[teamNum];
|
|
2601
|
+
if (!team) return;
|
|
2602
|
+
|
|
2603
|
+
const teamReposDir = path.join(process.env.HOME, "src/iriai/.features", feature.slug, "teams", String(teamNum), "repos");
|
|
2604
|
+
this.supervisor.scheduleRetry(agentId, () => {
|
|
2605
|
+
const prompt = buildOrchestratorPrompt({
|
|
2606
|
+
teamDir: team.dir,
|
|
2607
|
+
orchDir: team.orchestrator,
|
|
2608
|
+
task: "Resume from where you left off. Read HANDOVER.md and STEP-SUMMARY.md for progress.",
|
|
2609
|
+
recoveryContext: { retryCount: queries.getAgentById(agentId).retry_count },
|
|
2610
|
+
teamReposDir: fs.existsSync(teamReposDir) ? teamReposDir : null,
|
|
2611
|
+
});
|
|
2612
|
+
// Crash recovery — use --continue to resume the conversation
|
|
2613
|
+
return { prompt, continue: true };
|
|
2614
|
+
});
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
_handleRoleExit(agentId, feature, signalDir, roleName, elapsed) {
|
|
2618
|
+
const doneFile = path.join(signalDir, SIGNAL.DONE);
|
|
2619
|
+
if (fs.existsSync(doneFile)) {
|
|
2620
|
+
queries.updateAgentStatus(agentId, "done");
|
|
2621
|
+
return;
|
|
2622
|
+
}
|
|
2623
|
+
|
|
2624
|
+
// Guard: file watcher may have already processed .done and deleted it.
|
|
2625
|
+
const agentState = queries.getAgentById(agentId);
|
|
2626
|
+
if (agentState && agentState.status === "done") {
|
|
2627
|
+
console.log(`[orchestrator] Role ${roleName} exit: already marked done, skipping retry`);
|
|
2628
|
+
return;
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
const needsRestartFile = path.join(signalDir, SIGNAL.NEEDS_RESTART);
|
|
2632
|
+
if (fs.existsSync(needsRestartFile)) {
|
|
2633
|
+
try { fs.unlinkSync(needsRestartFile); } catch { /* ok */ }
|
|
2634
|
+
const handoverContent = readSignal(path.join(signalDir, SIGNAL.HANDOVER), { deleteAfter: true });
|
|
2635
|
+
queries.resetAgentRetry(agentId);
|
|
2636
|
+
// Re-spawn with handover
|
|
2637
|
+
const prompt = buildRolePrompt({
|
|
2638
|
+
role: roleName,
|
|
2639
|
+
signalDir,
|
|
2640
|
+
task: "Resume from handover context.",
|
|
2641
|
+
recoveryContext: handoverContent ? { type: "handover", content: handoverContent } : null,
|
|
2642
|
+
});
|
|
2643
|
+
this.supervisor.spawn(agentId, prompt);
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
|
|
2647
|
+
// Retry with crash recovery context — use --continue to resume conversation
|
|
2648
|
+
this.supervisor.scheduleRetry(agentId, (agent) => {
|
|
2649
|
+
const prompt = buildRolePrompt({
|
|
2650
|
+
role: roleName,
|
|
2651
|
+
signalDir,
|
|
2652
|
+
task: "Resume from where you left off.",
|
|
2653
|
+
recoveryContext: { type: "crash", retryCount: agent.retry_count },
|
|
2654
|
+
});
|
|
2655
|
+
return { prompt, continue: true };
|
|
2656
|
+
});
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2660
|
+
// OPERATOR RELAY QUEUE
|
|
2661
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2662
|
+
|
|
2663
|
+
/**
|
|
2664
|
+
* Enqueue agent output for formatting by the Operator before posting to the user.
|
|
2665
|
+
*/
|
|
2666
|
+
async _enqueueForOperatorRelay(feature, sourceAgent, eventHint, rawContent) {
|
|
2667
|
+
queries.insertRelayEntry({
|
|
2668
|
+
featureId: feature.id,
|
|
2669
|
+
sourceAgent,
|
|
2670
|
+
eventHint,
|
|
2671
|
+
rawContent,
|
|
2672
|
+
});
|
|
2673
|
+
|
|
2674
|
+
// Kick the serial queue processor
|
|
2675
|
+
this._processRelayQueue(feature.id);
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
/**
|
|
2679
|
+
* Serial queue processor per feature. Only one relay processes at a time.
|
|
2680
|
+
*/
|
|
2681
|
+
async _processRelayQueue(featureId) {
|
|
2682
|
+
if (this._relayProcessing[featureId]) return;
|
|
2683
|
+
this._relayProcessing[featureId] = true;
|
|
2684
|
+
|
|
2685
|
+
try {
|
|
2686
|
+
let entry;
|
|
2687
|
+
while ((entry = queries.getNextPendingRelay(featureId))) {
|
|
2688
|
+
await this._invokeOperatorRelayAndWait(featureId, entry);
|
|
2689
|
+
}
|
|
2690
|
+
} catch (err) {
|
|
2691
|
+
console.error("[orchestrator] Relay queue error:", err.message);
|
|
2692
|
+
} finally {
|
|
2693
|
+
delete this._relayProcessing[featureId];
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
/**
|
|
2698
|
+
* Invoke Operator for a single relay entry. Returns a promise that resolves when
|
|
2699
|
+
* impl:operatorResponse fires (marking posted) or on timeout (fallback direct post).
|
|
2700
|
+
*/
|
|
2701
|
+
async _invokeOperatorRelayAndWait(featureId, queueEntry) {
|
|
2702
|
+
const feature = queries.getFeatureById(featureId);
|
|
2703
|
+
if (!feature) return;
|
|
2704
|
+
|
|
2705
|
+
const tree = this._signalTrees[feature.slug];
|
|
2706
|
+
if (!tree?.operator) {
|
|
2707
|
+
// No operator dir — fallback to direct post
|
|
2708
|
+
await this._fallbackDirectPost(feature, queueEntry.source_agent, queueEntry.raw_content, queueEntry.id, queueEntry.event_hint);
|
|
2709
|
+
return;
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
const opKey = `op-${feature.slug}`;
|
|
2713
|
+
const opAgent = queries.getAgentByKey(opKey);
|
|
2714
|
+
if (!opAgent) {
|
|
2715
|
+
await this._fallbackDirectPost(feature, queueEntry.source_agent, queueEntry.raw_content, queueEntry.id, queueEntry.event_hint);
|
|
2716
|
+
return;
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
// Mark as processing
|
|
2720
|
+
queries.updateRelayStatus(queueEntry.id, "processing");
|
|
2721
|
+
|
|
2722
|
+
// Kill any existing operator session (shared key)
|
|
2723
|
+
this.supervisor.kill(opKey);
|
|
2724
|
+
|
|
2725
|
+
try {
|
|
2726
|
+
await new Promise((resolve, reject) => {
|
|
2727
|
+
// Store the waiter so impl:operatorResponse can resolve it
|
|
2728
|
+
this._relayWaiters[featureId] = { resolve, queueId: queueEntry.id };
|
|
2729
|
+
|
|
2730
|
+
// Set timeout for fallback
|
|
2731
|
+
const timer = setTimeout(() => {
|
|
2732
|
+
console.warn(`[orchestrator] Operator relay timed out for queue ${queueEntry.id}`);
|
|
2733
|
+
delete this._relayWaiters[featureId];
|
|
2734
|
+
this._fallbackDirectPost(feature, queueEntry.source_agent, queueEntry.raw_content, queueEntry.id, queueEntry.event_hint)
|
|
2735
|
+
.then(resolve)
|
|
2736
|
+
.catch(reject);
|
|
2737
|
+
}, OPERATOR_RELAY_TIMEOUT_MS);
|
|
2738
|
+
|
|
2739
|
+
this._relayWaiters[featureId].timer = timer;
|
|
2740
|
+
|
|
2741
|
+
// Spawn the Operator in relay mode (--continue if it has a prior session)
|
|
2742
|
+
const opHasSession = opAgent.started_at != null;
|
|
2743
|
+
invokeOperatorRelay({
|
|
2744
|
+
feature,
|
|
2745
|
+
queueEntry,
|
|
2746
|
+
supervisor: this.supervisor,
|
|
2747
|
+
agentId: opAgent.id,
|
|
2748
|
+
operatorDir: tree.operator,
|
|
2749
|
+
featureDir: tree.featureDir,
|
|
2750
|
+
continue: opHasSession,
|
|
2751
|
+
}).catch(err => {
|
|
2752
|
+
console.error("[orchestrator] Operator relay spawn error:", err.message);
|
|
2753
|
+
clearTimeout(timer);
|
|
2754
|
+
delete this._relayWaiters[featureId];
|
|
2755
|
+
this._fallbackDirectPost(feature, queueEntry.source_agent, queueEntry.raw_content, queueEntry.id, queueEntry.event_hint)
|
|
2756
|
+
.then(resolve)
|
|
2757
|
+
.catch(reject);
|
|
2758
|
+
});
|
|
2759
|
+
});
|
|
2760
|
+
} catch (err) {
|
|
2761
|
+
console.error("[orchestrator] Relay wait error:", err.message);
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
/**
|
|
2766
|
+
* Resolve a pending relay waiter (called when impl:operatorResponse fires).
|
|
2767
|
+
*/
|
|
2768
|
+
_resolveRelayWaiter(featureId) {
|
|
2769
|
+
const waiter = this._relayWaiters[featureId];
|
|
2770
|
+
if (!waiter) return;
|
|
2771
|
+
|
|
2772
|
+
clearTimeout(waiter.timer);
|
|
2773
|
+
// Mark queue entry as posted
|
|
2774
|
+
queries.updateRelayStatus(waiter.queueId, "posted", { processedAt: new Date().toISOString() });
|
|
2775
|
+
delete this._relayWaiters[featureId];
|
|
2776
|
+
waiter.resolve();
|
|
2777
|
+
|
|
2778
|
+
// Post any deferred decision now that the Operator message is visible
|
|
2779
|
+
this._postDeferredDecision(featureId);
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
/**
|
|
2783
|
+
* Post a deferred decision (stored by _requestPhaseReview) after the Operator
|
|
2784
|
+
* relay completes, so the decision buttons appear after the Operator's message.
|
|
2785
|
+
*/
|
|
2786
|
+
async _postDeferredDecision(featureId) {
|
|
2787
|
+
const deferred = this._deferredDecisions[featureId];
|
|
2788
|
+
if (!deferred) return;
|
|
2789
|
+
delete this._deferredDecisions[featureId];
|
|
2790
|
+
|
|
2791
|
+
try {
|
|
2792
|
+
await this.adapter.postDecision(deferred.featureId, deferred.decision);
|
|
2793
|
+
} catch (err) {
|
|
2794
|
+
console.error("[orchestrator] Failed to post deferred decision:", err.message);
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
/**
|
|
2799
|
+
* Fallback: post raw agent content directly when Operator fails or times out.
|
|
2800
|
+
* @param {string} eventHint - optional hint from the relay entry
|
|
2801
|
+
*/
|
|
2802
|
+
async _fallbackDirectPost(feature, sourceAgent, rawContent, queueId, eventHint) {
|
|
2803
|
+
// For decision-needed relays: skip text fallback but post the deferred decision
|
|
2804
|
+
if (eventHint === "decision-needed") {
|
|
2805
|
+
console.log(`[orchestrator] Operator relay timed out for decision-needed (queue ${queueId}) — posting decision directly`);
|
|
2806
|
+
queries.updateRelayStatus(queueId, "posted", { processedAt: new Date().toISOString() });
|
|
2807
|
+
await this._postDeferredDecision(feature.id);
|
|
2808
|
+
return;
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
console.warn(`[orchestrator] Fallback direct post for ${sourceAgent} (queue ${queueId})`);
|
|
2812
|
+
try {
|
|
2813
|
+
await this.adapter.postAgentResponse(feature.id, sourceAgent, rawContent);
|
|
2814
|
+
queries.updateRelayStatus(queueId, "posted", { processedAt: new Date().toISOString() });
|
|
2815
|
+
} catch (err) {
|
|
2816
|
+
console.error("[orchestrator] Fallback post error:", err.message);
|
|
2817
|
+
queries.updateRelayStatus(queueId, "failed");
|
|
2818
|
+
queries.incrementRelayRetry(queueId);
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2823
|
+
// STALE SIGNAL SAFETY NET
|
|
2824
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2825
|
+
|
|
2826
|
+
startStaleScan() {
|
|
2827
|
+
this._staleScanTimer = setInterval(() => this._scanForStaleSignals(), STALE_SCAN_INTERVAL_MS);
|
|
2828
|
+
this._staleScanTimer.unref();
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
_scanForStaleSignals() {
|
|
2832
|
+
for (const [slug, tree] of Object.entries(this._signalTrees)) {
|
|
2833
|
+
const feature = queries.getFeatureBySlug(slug);
|
|
2834
|
+
if (!feature || feature.phase === "complete" || feature.phase === "failed") continue;
|
|
2835
|
+
|
|
2836
|
+
// Check for stale gate-ready — skip if FL already running or gate evidence already posted
|
|
2837
|
+
const flAgent = queries.getAgentByKey(`fl-${slug}`);
|
|
2838
|
+
const flRunning = flAgent && (flAgent.status === "running" || this.supervisor.isRunning(`fl-${slug}`));
|
|
2839
|
+
if (!flRunning && !feature.gate_evidence_ts) {
|
|
2840
|
+
const allReady = Object.keys(tree.teams).every(tn =>
|
|
2841
|
+
tree.teams[tn].orchestrator && fs.existsSync(path.join(tree.teams[tn].orchestrator, SIGNAL.GATE_READY))
|
|
2842
|
+
);
|
|
2843
|
+
if (allReady && Object.keys(tree.teams).length > 0) {
|
|
2844
|
+
console.log(`[orchestrator] Stale scan: all teams gate-ready for ${slug}`);
|
|
2845
|
+
this._triggerFeatureLead(slug, "gate-ready");
|
|
2846
|
+
continue;
|
|
2847
|
+
}
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
// Check for stale questions
|
|
2851
|
+
const questionTeams = Object.keys(tree.teams).filter(tn =>
|
|
2852
|
+
tree.teams[tn].orchestrator && fs.existsSync(path.join(tree.teams[tn].orchestrator, SIGNAL.QUESTION))
|
|
2853
|
+
);
|
|
2854
|
+
if (questionTeams.length > 0) {
|
|
2855
|
+
console.log(`[orchestrator] Stale scan: questions pending for ${slug}`);
|
|
2856
|
+
this._triggerFeatureLead(slug, "question", { questionTeams });
|
|
2857
|
+
continue;
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
// Check for stale crashes
|
|
2861
|
+
const crashedTeams = Object.keys(tree.teams).filter(tn =>
|
|
2862
|
+
tree.teams[tn].orchestrator && fs.existsSync(path.join(tree.teams[tn].orchestrator, SIGNAL.CRASHED))
|
|
2863
|
+
);
|
|
2864
|
+
if (crashedTeams.length > 0) {
|
|
2865
|
+
console.log(`[orchestrator] Stale scan: crashes pending for ${slug}`);
|
|
2866
|
+
this._triggerFeatureLead(slug, "crash", { crashedTeams });
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2872
|
+
// SHUTDOWN
|
|
2873
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2874
|
+
|
|
2875
|
+
async shutdown() {
|
|
2876
|
+
if (this._staleScanTimer) {
|
|
2877
|
+
clearInterval(this._staleScanTimer);
|
|
2878
|
+
this._staleScanTimer = null;
|
|
2879
|
+
}
|
|
2880
|
+
if (this.reviewSessions) {
|
|
2881
|
+
await this.reviewSessions.stopAll();
|
|
2882
|
+
}
|
|
2883
|
+
await this.supervisor.shutdown();
|
|
2884
|
+
await this.fileIO.closeAll();
|
|
2885
|
+
}
|
|
2886
|
+
}
|