Skip to content

Commit b0a4e74

Browse files
authored
feat: surface unblock task governance on operator views (#77)
1 parent 7e4e80d commit b0a4e74

6 files changed

Lines changed: 149 additions & 6 deletions

File tree

apps/dashboard/app/pm/components/PMIntakeRightSidebar.tsx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,26 @@ function summarizeWorkerPromptContracts(report: ExecutionPlanReport): Array<{
138138
});
139139
}
140140

141+
function summarizeUnblockTasks(report: ExecutionPlanReport): Array<{
142+
id: string;
143+
owner: string;
144+
mode: string;
145+
trigger: string;
146+
}> {
147+
const rawTasks = Array.isArray(report.unblock_tasks) ? report.unblock_tasks : [];
148+
return rawTasks
149+
.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item))
150+
.map((item) => {
151+
const record = item as Record<string, unknown>;
152+
return {
153+
id: String(record.unblock_task_id || "-").trim() || "-",
154+
owner: String(record.owner || "-").trim() || "-",
155+
mode: String(record.mode || "-").trim() || "-",
156+
trigger: String(record.trigger || "-").trim() || "-",
157+
};
158+
});
159+
}
160+
141161
export default function PMIntakeRightSidebar(props: Props) {
142162
const {
143163
pmJourneyContext,
@@ -305,6 +325,7 @@ export default function PMIntakeRightSidebar(props: Props) {
305325
const flightPlanPredictedArtifacts = executionPlanPreview ? compactList(executionPlanPreview.predicted_artifacts) : "-";
306326
const flightPlanAcceptanceChecks = executionPlanPreview ? summarizeAcceptanceChecks(executionPlanPreview) : "-";
307327
const workerPromptContracts = executionPlanPreview ? summarizeWorkerPromptContracts(executionPlanPreview) : [];
328+
const unblockTasks = executionPlanPreview ? summarizeUnblockTasks(executionPlanPreview) : [];
308329
const wavePlan = executionPlanPreview && typeof executionPlanPreview.wave_plan === "object" && executionPlanPreview.wave_plan
309330
? (executionPlanPreview.wave_plan as Record<string, unknown>)
310331
: null;
@@ -624,6 +645,18 @@ export default function PMIntakeRightSidebar(props: Props) {
624645
</ul>
625646
</>
626647
) : null}
648+
{unblockTasks.length > 0 ? (
649+
<>
650+
<strong>Unblock task candidates</strong>
651+
<ul className="pm-question-list">
652+
{unblockTasks.map((item) => (
653+
<li key={item.id}>
654+
<span className="mono">{item.id}</span> · owner {item.owner} · {item.mode} · trigger {item.trigger}
655+
</li>
656+
))}
657+
</ul>
658+
</>
659+
) : null}
627660
<details>
628661
<summary className="pm-details-summary">Contract preview excerpts</summary>
629662
<div className="mono">allowed paths: {flightPlanAllowedPaths}</div>

apps/dashboard/components/RunDetail.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ export default function RunDetail({
8181
const [toolCallsLoading, setToolCallsLoading] = useState(false);
8282
const [planningContracts, setPlanningContracts] = useState<Array<Record<string, unknown>>>([]);
8383
const [planningContractsError, setPlanningContractsError] = useState("");
84+
const [unblockTasks, setUnblockTasks] = useState<Array<Record<string, unknown>>>([]);
85+
const [unblockTasksError, setUnblockTasksError] = useState("");
8486
const [chainSpecError, setChainSpecError] = useState("");
8587
const [chainSpecLoading, setChainSpecLoading] = useState(false);
8688
const [liveEnabled, setLiveEnabled] = useState(true);
@@ -139,6 +141,12 @@ export default function RunDetail({
139141
const path = toStringOr(record.path, "");
140142
return name === "planning_worker_prompt_contracts" || path === "artifacts/planning_worker_prompt_contracts.json";
141143
});
144+
const hasUnblockTasksArtifact = manifestArtifacts.some((item) => {
145+
const record = toObject(item);
146+
const name = toStringOr(record.name, "");
147+
const path = toStringOr(record.path, "");
148+
return name === "planning_unblock_tasks" || path === "artifacts/planning_unblock_tasks.json";
149+
});
142150
const observability = toObject(run?.manifest?.observability);
143151
const summaryGroups = ["reports/", "events.jsonl", "contract.json", "other"];
144152
const summary = summaryGroups.map((group) => {
@@ -276,6 +284,41 @@ export default function RunDetail({
276284
};
277285
}, [hasPlanningContractsArtifact, run?.run_id]);
278286

287+
useEffect(() => {
288+
let alive = true;
289+
async function loadUnblockTasks() {
290+
if (!run?.run_id || !hasUnblockTasksArtifact) {
291+
if (alive) {
292+
setUnblockTasks([]);
293+
setUnblockTasksError("");
294+
}
295+
return;
296+
}
297+
try {
298+
const artifact = await fetchArtifact(run.run_id, "planning_unblock_tasks.json");
299+
const rows = Array.isArray(artifact?.data) ? artifact.data : [];
300+
if (alive) {
301+
setUnblockTasks(
302+
rows
303+
.map((item) => (item && typeof item === "object" && !Array.isArray(item) ? (item as Record<string, unknown>) : null))
304+
.filter((item): item is Record<string, unknown> => item !== null),
305+
);
306+
setUnblockTasksError("");
307+
}
308+
} catch (err: unknown) {
309+
if (alive) {
310+
setUnblockTasks([]);
311+
console.error(`[run-detail] load unblock tasks failed: ${uiErrorDetail(err)}`);
312+
setUnblockTasksError(sanitizeUiError(err, "Unblock task summary unavailable"));
313+
}
314+
}
315+
}
316+
void loadUnblockTasks();
317+
return () => {
318+
alive = false;
319+
};
320+
}, [hasUnblockTasksArtifact, run?.run_id]);
321+
279322
useEffect(() => {
280323
let alive = true;
281324
async function loadChainSpec() {
@@ -491,6 +534,8 @@ export default function RunDetail({
491534
manifestArtifacts={manifestArtifacts}
492535
planningContracts={planningContracts}
493536
planningContractsError={planningContractsError}
537+
unblockTasks={unblockTasks}
538+
unblockTasksError={unblockTasksError}
494539
onOpenLogs={() => handleFailedTerminalAction("logs")}
495540
onOpenReports={() => handleFailedTerminalAction("reports")}
496541
failedTerminalActionFeedback={failedTerminalActionFeedback}

apps/dashboard/components/run-detail/RunDetailStatusContractCard.tsx

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ type RunDetailStatusContractCardProps = {
4040
manifestArtifacts: unknown[];
4141
planningContracts: Array<Record<string, unknown>>;
4242
planningContractsError: string;
43+
unblockTasks: Array<Record<string, unknown>>;
44+
unblockTasksError: string;
4345
onOpenLogs: () => void;
4446
onOpenReports: () => void;
4547
failedTerminalActionFeedback: string;
@@ -68,6 +70,8 @@ export default function RunDetailStatusContractCard({
6870
manifestArtifacts,
6971
planningContracts,
7072
planningContractsError,
73+
unblockTasks,
74+
unblockTasksError,
7175
onOpenLogs,
7276
onOpenReports,
7377
failedTerminalActionFeedback,
@@ -97,6 +101,9 @@ export default function RunDetailStatusContractCard({
97101
artifactNames.includes("artifacts/planning_worker_prompt_contracts.json")
98102
? "Worker prompt contracts"
99103
: "",
104+
artifactNames.includes("planning_unblock_tasks") || artifactNames.includes("artifacts/planning_unblock_tasks.json")
105+
? "Unblock tasks"
106+
: "",
100107
].filter(Boolean);
101108
const continuationOnIncomplete = Array.from(
102109
new Set(
@@ -122,6 +129,15 @@ export default function RunDetailStatusContractCard({
122129
),
123130
);
124131
const roleBindingReadModel = run.role_binding_read_model;
132+
const unblockTaskOwners = Array.from(
133+
new Set(unblockTasks.map((task) => toDisplayText(task.owner)).filter((value) => value !== "-")),
134+
);
135+
const unblockTaskModes = Array.from(
136+
new Set(unblockTasks.map((task) => toDisplayText(task.mode)).filter((value) => value !== "-")),
137+
);
138+
const unblockTaskTriggers = Array.from(
139+
new Set(unblockTasks.map((task) => toDisplayText(task.trigger)).filter((value) => value !== "-")),
140+
);
125141

126142
return (
127143
<Card>
@@ -265,10 +281,11 @@ export default function RunDetailStatusContractCard({
265281
</div>
266282
</div>
267283
) : null}
268-
{planningContracts.length > 0 || planningContractsError ? (
284+
{planningContracts.length > 0 || planningContractsError || unblockTasks.length > 0 || unblockTasksError ? (
269285
<div className="run-detail-section" data-testid="run-completion-governance-summary">
270286
<div className="mono run-detail-section-label">Completion governance</div>
271287
<div className="mono">Worker prompt contracts: {planningContracts.length}</div>
288+
{unblockTasks.length > 0 ? <div className="mono">Unblock tasks: {unblockTasks.length}</div> : null}
272289
{continuationOnIncomplete.length > 0 ? (
273290
<div className="mono">On incomplete: {continuationOnIncomplete.join(" / ")}</div>
274291
) : null}
@@ -278,11 +295,20 @@ export default function RunDetailStatusContractCard({
278295
{doneChecks.length > 0 ? (
279296
<div className="mono">DoD checks: {doneChecks.join(" / ")}</div>
280297
) : null}
281-
{planningContractsError ? (
282-
<div className="mono muted">{planningContractsError}</div>
298+
{unblockTaskOwners.length > 0 ? (
299+
<div className="mono">Unblock owner: {unblockTaskOwners.join(" / ")}</div>
300+
) : null}
301+
{unblockTaskModes.length > 0 ? (
302+
<div className="mono">Unblock mode: {unblockTaskModes.join(" / ")}</div>
303+
) : null}
304+
{unblockTaskTriggers.length > 0 ? (
305+
<div className="mono">Unblock trigger: {unblockTaskTriggers.join(" / ")}</div>
306+
) : null}
307+
{planningContractsError || unblockTasksError ? (
308+
<div className="mono muted">{planningContractsError || unblockTasksError}</div>
283309
) : (
284310
<div className="mono muted">
285-
Derived from persisted worker prompt contracts. These summaries stay advisory; task_contract still owns execution authority.
311+
Derived from persisted worker prompt contracts and unblock tasks. These summaries stay advisory; task_contract still owns execution authority.
286312
</div>
287313
)}
288314
</div>

apps/dashboard/tests/pm_intake_components_branches.test.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,14 @@ describe("pm intake right sidebar component branches", () => {
352352
verification_requirements: ["repo_hygiene"],
353353
},
354354
],
355+
unblock_tasks: [
356+
{
357+
unblock_task_id: "unblock-worker-prompt-1",
358+
owner: "L0",
359+
mode: "independent_temporary_task",
360+
trigger: "spawn_independent_temporary_unblock_task",
361+
},
362+
],
355363
contract_preview: {
356364
assigned_agent: { role: "WORKER", agent_id: "agent-1" },
357365
owner_agent: { role: "TECH_LEAD", agent_id: "agent-2" },
@@ -373,7 +381,9 @@ describe("pm intake right sidebar component branches", () => {
373381
expect(screen.getByText("Wave plan snapshot")).toBeInTheDocument();
374382
expect(screen.getByText(/Wave ID: bundle-preview-1/)).toBeInTheDocument();
375383
expect(screen.getByText("Worker prompt contracts")).toBeInTheDocument();
376-
expect(screen.getByText(/worker-prompt-1/)).toBeInTheDocument();
384+
expect(screen.getByText(/^worker-prompt-1$/)).toBeInTheDocument();
385+
expect(screen.getByText("Unblock task candidates")).toBeInTheDocument();
386+
expect(screen.getByText(/unblock-worker-prompt-1/)).toBeInTheDocument();
377387
expect(screen.getByText("Contract preview excerpts")).toBeInTheDocument();
378388
expect(screen.getByText("Advanced planning payloads")).toBeInTheDocument();
379389
});

apps/dashboard/tests/rundetail.shared.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ type FetchOptions = {
99
reports?: any[];
1010
chainSpec?: any;
1111
planningContracts?: any[];
12+
planningUnblockTasks?: any[];
1213
availableRuns?: any[];
1314
agentStatus?: any[];
1415
toolCalls?: any[];
@@ -19,11 +20,13 @@ type FetchOptions = {
1920
reportsOk?: boolean;
2021
chainOk?: boolean;
2122
planningContractsOk?: boolean;
23+
planningUnblockTasksOk?: boolean;
2224
agentStatusOk?: boolean;
2325
toolCallsOk?: boolean;
2426
throwRuns?: boolean;
2527
throwChain?: boolean;
2628
throwPlanningContracts?: boolean;
29+
throwPlanningUnblockTasks?: boolean;
2730
throwReplay?: boolean;
2831
throwAgentStatus?: boolean;
2932
throwToolCalls?: boolean;
@@ -36,6 +39,7 @@ export function mockFetchFactory(options: FetchOptions) {
3639
reports = [],
3740
chainSpec = null,
3841
planningContracts = [],
42+
planningUnblockTasks = [],
3943
availableRuns = [],
4044
replayOk = true,
4145
replayStatus = 200,
@@ -44,13 +48,15 @@ export function mockFetchFactory(options: FetchOptions) {
4448
reportsOk = true,
4549
chainOk = true,
4650
planningContractsOk = true,
51+
planningUnblockTasksOk = true,
4752
agentStatus = [],
4853
toolCalls = [],
4954
agentStatusOk = true,
5055
toolCallsOk = true,
5156
throwRuns = false,
5257
throwChain = false,
5358
throwPlanningContracts = false,
59+
throwPlanningUnblockTasks = false,
5460
throwReplay = false,
5561
throwAgentStatus = false,
5662
throwToolCalls = false,
@@ -75,6 +81,16 @@ export function mockFetchFactory(options: FetchOptions) {
7581
json: async () => ({ data: planningContracts }),
7682
};
7783
}
84+
if (url.includes("/api/runs/") && url.includes("/artifacts?name=planning_unblock_tasks.json")) {
85+
if (throwPlanningUnblockTasks) {
86+
throw new Error("planning unblock tasks failed");
87+
}
88+
return {
89+
ok: planningUnblockTasksOk,
90+
status: planningUnblockTasksOk ? 200 : 500,
91+
json: async () => ({ data: planningUnblockTasks }),
92+
};
93+
}
7894
if (url.includes("/api/runs/") && url.includes("/artifacts?name=tool_calls.jsonl")) {
7995
if (throwToolCalls) {
8096
throw new Error("tool calls failed");

apps/dashboard/tests/rundetail_core.suite.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ describe("RunDetail core flows", () => {
212212
{ name: "prompt_artifact", path: "artifacts/prompt_artifact.json" },
213213
{ name: "planning_wave_plan", path: "artifacts/planning_wave_plan.json" },
214214
{ name: "planning_worker_prompt_contracts", path: "artifacts/planning_worker_prompt_contracts.json" },
215+
{ name: "planning_unblock_tasks", path: "artifacts/planning_unblock_tasks.json" },
215216
],
216217
},
217218
};
@@ -229,6 +230,14 @@ describe("RunDetail core flows", () => {
229230
},
230231
},
231232
],
233+
planningUnblockTasks: [
234+
{
235+
unblock_task_id: "unblock-worker-1",
236+
owner: "L0",
237+
mode: "independent_temporary_task",
238+
trigger: "spawn_independent_temporary_unblock_task",
239+
},
240+
],
232241
});
233242
// @ts-expect-error test override
234243
global.fetch = fetchMock;
@@ -249,10 +258,14 @@ describe("RunDetail core flows", () => {
249258
await waitFor(() => {
250259
expect(screen.getByText("Worker prompt contracts: 1")).toBeInTheDocument();
251260
});
261+
expect(screen.getByText("Unblock tasks: 1")).toBeInTheDocument();
252262
expect(screen.getByText("On incomplete: reply_auditor_reprompt_and_continue_same_session")).toBeInTheDocument();
253263
expect(screen.getByText("On blocked: spawn_independent_temporary_unblock_task")).toBeInTheDocument();
254264
expect(screen.getByText("DoD checks: repo_hygiene / test_report")).toBeInTheDocument();
255-
expect(screen.getByText("Lifecycle artifacts: Prompt artifact / Wave plan / Worker prompt contracts")).toBeInTheDocument();
265+
expect(screen.getByText("Unblock owner: L0")).toBeInTheDocument();
266+
expect(screen.getByText("Unblock mode: independent_temporary_task")).toBeInTheDocument();
267+
expect(screen.getByText("Unblock trigger: spawn_independent_temporary_unblock_task")).toBeInTheDocument();
268+
expect(screen.getByText("Lifecycle artifacts: Prompt artifact / Wave plan / Worker prompt contracts / Unblock tasks")).toBeInTheDocument();
256269
expect(
257270
screen.getByText(
258271
"Read-only note: this mirrors the persisted binding summary. task_contract still owns execution authority.",

0 commit comments

Comments
 (0)