pi-crew 0.10.1 → 0.10.2
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/CHANGELOG.md +98 -0
- package/NOTICE.md +21 -0
- package/README.md +44 -2
- package/agents/executor.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/dist/index.mjs +2781 -949
- package/package.json +1 -1
- package/schema.json +492 -94
- package/src/config/config-validation.ts +7 -1
- package/src/config/defaults.ts +8 -1
- package/src/config/role-tools.ts +4 -2
- package/src/config/types.ts +19 -1
- package/src/extension/crew-cleanup.ts +13 -0
- package/src/extension/crew-vibes/footer.ts +19 -0
- package/src/extension/crew-vibes/index.ts +11 -1
- package/src/extension/register.ts +8 -0
- package/src/extension/registration/command-registration.ts +1 -0
- package/src/extension/registration/commands/run.ts +15 -1
- package/src/extension/registration/commands/shared.ts +8 -0
- package/src/extension/registration/foreground-run-controller.ts +10 -2
- package/src/extension/registration/lifecycle-handlers.ts +52 -2
- package/src/extension/registration/runtime-cleanup.ts +23 -5
- package/src/extension/registration/team-tool.ts +5 -1
- package/src/extension/registration/ui.ts +5 -4
- package/src/extension/team-tool/run-deadline.ts +20 -3
- package/src/extension/team-tool/run.ts +26 -2
- package/src/runtime/child-pi/child-pi-constants.ts +8 -0
- package/src/runtime/child-pi/child-pi-streams.ts +21 -1
- package/src/runtime/detached-run-results.ts +90 -0
- package/src/runtime/goal-workflow/adaptive-plan.ts +30 -3
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +5 -1
- package/src/runtime/merge-gate.ts +25 -9
- package/src/runtime/model/model-fallback.ts +7 -3
- package/src/runtime/process-status.ts +16 -1
- package/src/runtime/recovery/crash-recovery.ts +16 -0
- package/src/runtime/run-tracker.ts +77 -10
- package/src/runtime/team-runner.ts +3 -2
- package/src/schema/config-schema.ts +1 -1
- package/src/state/stores/plan-store.ts +1 -1
- package/src/state/stores/state-store.ts +73 -3
- package/src/state/types.ts +2 -0
- package/src/ui/dock-footer.ts +49 -0
- package/src/ui/inline-panel/agent-pane.ts +375 -0
- package/src/ui/inline-panel/agent-transcript.ts +338 -0
- package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
- package/src/ui/inline-panel/crew-editor.ts +192 -0
- package/src/ui/inline-panel/index.ts +290 -0
- package/src/ui/inline-panel/panel-rows.ts +37 -0
- package/src/ui/inline-panel/panel-selection.ts +157 -0
- package/src/ui/inline-panel/panel-store.ts +111 -0
- package/src/ui/inline-panel/view-session-store.ts +36 -0
- package/src/ui/pi-ui-compat.ts +9 -0
- package/src/ui/widget/index.ts +176 -22
- package/src/ui/widget/task-list.ts +198 -0
- package/src/ui/widget/widget-formatters.ts +240 -4
- package/src/ui/widget/widget-renderer.ts +234 -37
- package/src/ui/widget/widget-types.ts +11 -0
- package/src/utils/child-process-shield.ts +106 -0
- package/src/utils/redaction.ts +7 -0
- package/src/utils/safe-abort.ts +45 -0
- package/src/workflows/discover-workflows.ts +1 -0
- package/src/workflows/workflow-config.ts +7 -0
- package/workflows/default.workflow.md +36 -26
- package/workflows/strict-fast-fix.workflow.md +26 -0
|
@@ -11,7 +11,13 @@
|
|
|
11
11
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
12
12
|
import { extractText } from "../output/pi-json-output.ts";
|
|
13
13
|
import type { ChildPiRunInput } from "./child-pi.ts";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
MAX_ASSISTANT_TEXT_CHARS,
|
|
16
|
+
MAX_LINE_BUFFER_BYTES,
|
|
17
|
+
MAX_THINKING_CHARS,
|
|
18
|
+
MAX_TOOL_INPUT_CHARS,
|
|
19
|
+
MAX_TOOL_RESULT_CHARS,
|
|
20
|
+
} from "./child-pi-constants.ts";
|
|
15
21
|
import { appendTranscript, compactString, compactValue, flushPendingTranscriptWrites } from "./child-pi-transcript.ts";
|
|
16
22
|
|
|
17
23
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
@@ -38,6 +44,20 @@ function compactContentPart(part: unknown): unknown | undefined {
|
|
|
38
44
|
name: record.name,
|
|
39
45
|
input: compactValue(typeof record.input === "string" ? compactString(record.input, MAX_TOOL_INPUT_CHARS) : record.input),
|
|
40
46
|
};
|
|
47
|
+
// Reasoning is display-only, but the agent view pane renders the FULL
|
|
48
|
+
// assistant message (AssistantMessageComponent) — without the thinking
|
|
49
|
+
// parts the pane would look sparse next to a real pi session. Keep them
|
|
50
|
+
// capped so a long-thinking model cannot balloon the event log.
|
|
51
|
+
if (record.type === "thinking")
|
|
52
|
+
return {
|
|
53
|
+
type: "thinking",
|
|
54
|
+
thinking:
|
|
55
|
+
typeof record.thinking === "string"
|
|
56
|
+
? compactString(record.thinking, MAX_THINKING_CHARS, {
|
|
57
|
+
preserveImportant: false,
|
|
58
|
+
})
|
|
59
|
+
: "",
|
|
60
|
+
};
|
|
41
61
|
if (record.type === "toolResult")
|
|
42
62
|
return {
|
|
43
63
|
type: "toolResult",
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Result delivery for runs detached by an agent-view switch.
|
|
3
|
+
*
|
|
4
|
+
* Opening an agent view mid-run releases the foreground waiter (see
|
|
5
|
+
* `detachRunPromise`), so the parent turn settles with a "detached" tool
|
|
6
|
+
* result and the run keeps executing. Without this module the run's FINAL
|
|
7
|
+
* result would never reach the conversation: the async notifier only toasts,
|
|
8
|
+
* and its toast is suppressed while a view session is active (different
|
|
9
|
+
* session id) or when the run finished before the operator returned to main.
|
|
10
|
+
*
|
|
11
|
+
* The registry is in-process; a pending result is parked while an agent view is
|
|
12
|
+
* open so the worker's own view session never receives the parent's report.
|
|
13
|
+
*/
|
|
14
|
+
import { loadRunManifestById } from "../state/stores/state-store.ts";
|
|
15
|
+
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
16
|
+
import { isFinishedRunStatus } from "./process-status.ts";
|
|
17
|
+
|
|
18
|
+
interface DetachedRun {
|
|
19
|
+
runId: string;
|
|
20
|
+
cwd: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const detachedRuns = new Map<string, DetachedRun>();
|
|
24
|
+
|
|
25
|
+
/** Record a run whose foreground waiter was released by a view switch. */
|
|
26
|
+
export function markRunDetached(runId: string, cwd: string): void {
|
|
27
|
+
if (!runId || !cwd) return;
|
|
28
|
+
detachedRuns.set(runId, { runId, cwd });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Cheap guard for hot paths (render tick): nothing to do when empty. */
|
|
32
|
+
export function hasDetachedRuns(): boolean {
|
|
33
|
+
return detachedRuns.size > 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function forgetDetachedRun(runId: string): void {
|
|
37
|
+
detachedRuns.delete(runId);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function clearDetachedRunsForTest(): void {
|
|
41
|
+
detachedRuns.clear();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Human-readable completion summary for a detached run. */
|
|
45
|
+
export function formatDetachedRunResult(manifest: TeamRunManifest, tasks: TeamTaskState[]): string {
|
|
46
|
+
const lines = [
|
|
47
|
+
`pi-crew run ${manifest.status}: ${manifest.runId} (${manifest.team}/${manifest.workflow ?? "none"})`,
|
|
48
|
+
`Goal: ${manifest.goal}`,
|
|
49
|
+
"",
|
|
50
|
+
"This run was detached when you opened an agent view; it finished in the background.",
|
|
51
|
+
];
|
|
52
|
+
if (tasks.length > 0) {
|
|
53
|
+
lines.push("", "Tasks:");
|
|
54
|
+
for (const task of tasks) {
|
|
55
|
+
const detail = task.error ? ` — ${task.error}` : "";
|
|
56
|
+
lines.push(` • ${task.id} (${task.role ?? "?"}): ${task.status}${detail}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (manifest.artifactsRoot) lines.push("", `Artifacts: ${manifest.artifactsRoot}`);
|
|
60
|
+
lines.push(`State: ${manifest.stateRoot}`);
|
|
61
|
+
return lines.join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Results of finished detached runs that may be shown in the CURRENT session.
|
|
66
|
+
* Entries are NOT removed: the caller drops each one with `forgetDetachedRun`
|
|
67
|
+
* only after the delivery actually succeeded, so a failed send retries on the
|
|
68
|
+
* next tick instead of losing the run's outcome.
|
|
69
|
+
*
|
|
70
|
+
* Gating is by "am I inside an agent view", not by session id: the report must
|
|
71
|
+
* never land inside a worker's view session, but matching the owner's id is too
|
|
72
|
+
* brittle (the main session can be replaced/renamed while the view is open, and
|
|
73
|
+
* the registry is per pi process anyway, so any non-view session here IS the
|
|
74
|
+
* operator's own).
|
|
75
|
+
*/
|
|
76
|
+
export function peekFinishedDetachedRunResults(options: { inViewSession?: boolean } = {}): { runId: string; text: string }[] {
|
|
77
|
+
if (detachedRuns.size === 0 || options.inViewSession) return [];
|
|
78
|
+
const ready: { runId: string; text: string }[] = [];
|
|
79
|
+
for (const entry of [...detachedRuns.values()]) {
|
|
80
|
+
const loaded = loadRunManifestById(entry.cwd, entry.runId);
|
|
81
|
+
if (!loaded) {
|
|
82
|
+
// Run state vanished (pruned/deleted) — nothing left to report.
|
|
83
|
+
detachedRuns.delete(entry.runId);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!isFinishedRunStatus(loaded.manifest.status)) continue;
|
|
87
|
+
ready.push({ runId: entry.runId, text: formatDetachedRunResult(loaded.manifest, loaded.tasks) });
|
|
88
|
+
}
|
|
89
|
+
return ready;
|
|
90
|
+
}
|
|
@@ -368,6 +368,11 @@ export interface InjectAdaptivePlanInput {
|
|
|
368
368
|
tasks: TeamTaskState[];
|
|
369
369
|
workflow: WorkflowConfig;
|
|
370
370
|
team: TeamConfig;
|
|
371
|
+
/** False for scaffold (dry-run) runs: the planner never executes, so there
|
|
372
|
+
* is no plan to inject — and no plan to be missing. Reports neutral
|
|
373
|
+
* (neither injected nor missing) so the preview completes instead of
|
|
374
|
+
* blocking on a plan a dry-run can never produce. */
|
|
375
|
+
executeWorkers?: boolean;
|
|
371
376
|
}
|
|
372
377
|
|
|
373
378
|
export interface InjectAdaptivePlanResult {
|
|
@@ -377,8 +382,18 @@ export interface InjectAdaptivePlanResult {
|
|
|
377
382
|
missingPlan: boolean;
|
|
378
383
|
}
|
|
379
384
|
|
|
385
|
+
/**
|
|
386
|
+
* True for workflows whose `assess` step emits a plan the runtime injects as
|
|
387
|
+
* concrete tasks. The `implementation` workflow is adaptive by name (historic
|
|
388
|
+
* hard-code, kept for backward compat); any other workflow opts in via
|
|
389
|
+
* frontmatter `adaptive: true`.
|
|
390
|
+
*/
|
|
391
|
+
export function isAdaptiveWorkflow(workflow: WorkflowConfig): boolean {
|
|
392
|
+
return workflow.adaptive === true || workflow.name === "implementation";
|
|
393
|
+
}
|
|
394
|
+
|
|
380
395
|
export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput): Promise<InjectAdaptivePlanResult> {
|
|
381
|
-
if (input.workflow.
|
|
396
|
+
if (!isAdaptiveWorkflow(input.workflow) || input.executeWorkers === false)
|
|
382
397
|
return {
|
|
383
398
|
tasks: input.tasks,
|
|
384
399
|
workflow: input.workflow,
|
|
@@ -504,11 +519,22 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
|
|
|
504
519
|
const itemId = `adaptive-p${phaseIndex + 1}-t${taskIndex + 1}`;
|
|
505
520
|
const stepId = `adaptive-${phaseIndex + 1}-${taskIndex + 1}-${slug(planned.role)}`;
|
|
506
521
|
const taskId = `adaptive-${String(counter).padStart(2, "0")}-${slug(planned.role)}`;
|
|
522
|
+
// Same display contract as static workflow steps: the planner's
|
|
523
|
+
// title (or the task text's first line) names the task; the full
|
|
524
|
+
// task text is the description for detail surfaces. Never the bare
|
|
525
|
+
// stepId — the task list is the plan, not an id roster.
|
|
526
|
+
const firstLine =
|
|
527
|
+
planned.task
|
|
528
|
+
.split("\n")
|
|
529
|
+
.map((line) => line.trim())
|
|
530
|
+
.filter(Boolean)
|
|
531
|
+
.find((line) => !line.startsWith("#")) ?? "";
|
|
532
|
+
const title = (planned.title ?? firstLine ?? stepId).slice(0, 120);
|
|
507
533
|
phaseItemIds.push(itemId);
|
|
508
534
|
planItems.push({
|
|
509
535
|
id: itemId,
|
|
510
536
|
ref: stepId,
|
|
511
|
-
title
|
|
537
|
+
title,
|
|
512
538
|
taskIds: [],
|
|
513
539
|
specIds: [],
|
|
514
540
|
acceptance: [],
|
|
@@ -527,7 +553,8 @@ export async function injectAdaptivePlanIfReady(input: InjectAdaptivePlanInput):
|
|
|
527
553
|
stepId,
|
|
528
554
|
role: planned.role,
|
|
529
555
|
agent: input.team.roles.find((role) => role.name === planned.role)?.agent ?? planned.role,
|
|
530
|
-
title
|
|
556
|
+
title,
|
|
557
|
+
description: planned.task !== title ? planned.task : undefined,
|
|
531
558
|
status: "queued",
|
|
532
559
|
dependsOn: previousStepIds,
|
|
533
560
|
cwd: input.manifest.cwd,
|
|
@@ -26,6 +26,7 @@ import { writeArtifact } from "../../state/stores/artifact-store.ts";
|
|
|
26
26
|
import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
|
|
27
27
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
28
28
|
import { packageRoot, projectCrewRoot, userPiRoot } from "../../utils/paths.ts";
|
|
29
|
+
import { safeAbort } from "../../utils/safe-abort.ts";
|
|
29
30
|
import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
|
|
30
31
|
import type { DynamicWorkflowConfig } from "../../workflows/workflow-config.ts";
|
|
31
32
|
import { assertDeterministicScript, isDeterminismCheckEnabled } from "../deterministic-ast.ts";
|
|
@@ -231,7 +232,10 @@ export async function runDynamicWorkflow(input: RunDynamicWorkflowInput): Promis
|
|
|
231
232
|
// ERR-1: abort the timeout controller so the abort propagates to
|
|
232
233
|
// runChildPi (via the combined signal passed to makeWorkflowCtx),
|
|
233
234
|
// which kills spawned children instead of letting them run.
|
|
234
|
-
|
|
235
|
+
// safeAbort: the combined signal may feed a signal-spawned child
|
|
236
|
+
// (abortChildProcess AbortError would otherwise reach the
|
|
237
|
+
// uncaughtException handler from this timer callback).
|
|
238
|
+
safeAbort(timeoutController, "dwf-script-timeout");
|
|
235
239
|
reject(
|
|
236
240
|
new Error(
|
|
237
241
|
`Dynamic workflow script timed out after ${SCRIPT_TIMEOUT_MS}ms. The script may have spawned a child process that did not exit. Check for spawn/exec calls without proper stdio handling.`,
|
|
@@ -181,15 +181,31 @@ export function mergeTaskUpdatesPreservingTerminal(base: TeamTaskState[], result
|
|
|
181
181
|
const current = indexById.get(updated.id);
|
|
182
182
|
if (!current) continue;
|
|
183
183
|
if (!shouldMergeTaskUpdate(current, updated)) {
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
console
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
184
|
+
// Routine no-op skips (an unchanged queued/running snapshot
|
|
185
|
+
// arriving from a parallel worker) are NORMAL parallel-merge
|
|
186
|
+
// behaviour and must stay silent — pi renders extension
|
|
187
|
+
// console output inline in the session transcript, so logging
|
|
188
|
+
// every skip floods whatever session is open (main or an
|
|
189
|
+
// agent view). Only a REAL rejected change (status transition,
|
|
190
|
+
// completion time, or result) is surfaced, as a warning: in
|
|
191
|
+
// distributed systems, rejected transitions may indicate bugs
|
|
192
|
+
// (wrong status, timestamp corruption) if they accumulate.
|
|
193
|
+
const meaningfulDiff =
|
|
194
|
+
updated.status !== current.status ||
|
|
195
|
+
updated.finishedAt !== current.finishedAt ||
|
|
196
|
+
updated.startedAt !== current.startedAt ||
|
|
197
|
+
Boolean(updated.resultArtifact) !== Boolean(current.resultArtifact) ||
|
|
198
|
+
Boolean(updated.error) ||
|
|
199
|
+
Boolean(updated.modelAttempts?.length) ||
|
|
200
|
+
Boolean(updated.usage);
|
|
201
|
+
if (meaningfulDiff) {
|
|
202
|
+
console.warn("[merge-gate] Rejected update for task", updated.id, {
|
|
203
|
+
currentStatus: current.status,
|
|
204
|
+
updatedStatus: updated.status,
|
|
205
|
+
currentFinishedAt: current.finishedAt,
|
|
206
|
+
updatedFinishedAt: updated.finishedAt,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
193
209
|
skipped += 1;
|
|
194
210
|
continue;
|
|
195
211
|
}
|
|
@@ -282,9 +282,13 @@ function warnUnvalidatedPassthrough(model: string, reason: string): void {
|
|
|
282
282
|
const key = `${model}|${reason}`;
|
|
283
283
|
if (passthroughWarned.has(key)) return;
|
|
284
284
|
passthroughWarned.add(key);
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
)
|
|
285
|
+
// Muted: per-model passthrough noise became too loud when many pi-crew
|
|
286
|
+
// child workers resolve their parent model + fallback chain against the
|
|
287
|
+
// full catalog (each worker printed one line). Dedup Set is preserved
|
|
288
|
+
// so re-enabling is a one-line revert.
|
|
289
|
+
// console.warn(
|
|
290
|
+
// `[model-routing] unvalidated passthrough: '${model}' (${reason}) — not confirmed against the configured model catalog; spawn may fail at the provider.`,
|
|
291
|
+
// );
|
|
288
292
|
}
|
|
289
293
|
|
|
290
294
|
/** Test seam: the dedup set is module-global (one warn per model+reason per
|
|
@@ -25,6 +25,12 @@ const ERROR_VISIBILITY_GRACE_MS = 10 * 60 * 1000;
|
|
|
25
25
|
/** Maximum age (ms) for an active run before it's considered stale.
|
|
26
26
|
* After this time, PID-only liveness is unreliable due to PID recycling. */
|
|
27
27
|
const STALE_ACTIVE_RUN_MS = 30 * 60 * 1000;
|
|
28
|
+
/** Window (ms) during which an active run whose agents are all
|
|
29
|
+
* completed/queued-without-evidence still counts as display-active: the
|
|
30
|
+
* runner just wrote the manifest (task transition) and the next worker has
|
|
31
|
+
* not recorded progress yet. Without this the dock flickers off between
|
|
32
|
+
* phases of a healthy run. */
|
|
33
|
+
const SPAWN_GAP_VISIBILITY_MS = 60 * 1000;
|
|
28
34
|
|
|
29
35
|
export function checkProcessLiveness(pid: number | undefined): ProcessLiveness {
|
|
30
36
|
if (pid === undefined || !Number.isInteger(pid) || pid <= 0) {
|
|
@@ -150,5 +156,14 @@ export function isDisplayActiveRun(run: TeamRunManifest, agents: CrewAgentRecord
|
|
|
150
156
|
// runs, or from cross-cwd registry history; showing them causes noisy 0/0 rows and
|
|
151
157
|
// needless spinner redraws. The full dashboard can still list historical runs.
|
|
152
158
|
if (agents.length === 0) return false;
|
|
153
|
-
|
|
159
|
+
if (agents.some(hasDurableActiveAgentEvidence)) return true;
|
|
160
|
+
// Spawn gap between phases: an active run whose manifest was JUST written
|
|
161
|
+
// and whose next agents are queued/waiting without evidence yet stays
|
|
162
|
+
// visible — the runner is alive and about to spawn them. Stale manifests
|
|
163
|
+
// (zombies) fall through and stay hidden.
|
|
164
|
+
if (agents.some((agent) => agent.status === "queued" || agent.status === "waiting")) {
|
|
165
|
+
const updatedAt = new Date(run.updatedAt).getTime();
|
|
166
|
+
if (Number.isFinite(updatedAt) && now - updatedAt < SPAWN_GAP_VISIBILITY_MS) return true;
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
154
169
|
}
|
|
@@ -283,6 +283,22 @@ export function cancelOrphanedRuns(
|
|
|
283
283
|
continue;
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
+
// Foreground (in-process) runs have NO async worker PID, so the liveness
|
|
287
|
+
// check above cannot apply. They execute inside THIS pi process — a
|
|
288
|
+
// recently-updated manifest conclusively proves the run is alive; only a
|
|
289
|
+
// run that has gone quiet for the stale threshold can be an orphan (a
|
|
290
|
+
// crashed process stops updating). Without this, a YOUNG foreground run
|
|
291
|
+
// (first worker heartbeat not yet written) was orphan-cancelled by the
|
|
292
|
+
// session that opened its agent view seconds after the run started —
|
|
293
|
+
// killing the very run the user was looking at.
|
|
294
|
+
if (!manifest.async) {
|
|
295
|
+
const updatedMs = manifest.updatedAt ? new Date(manifest.updatedAt).getTime() : Number.NaN;
|
|
296
|
+
if (Number.isFinite(updatedMs) && now - updatedMs <= staleThresholdMs) {
|
|
297
|
+
skipped.push(manifest.runId);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
286
302
|
// Check for recent heartbeat activity
|
|
287
303
|
const loaded = loadRunManifestById(cwd, manifest.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
|
|
288
304
|
if (!loaded) continue;
|
|
@@ -5,21 +5,38 @@ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
|
5
5
|
import { projectCrewRoot } from "../utils/paths.ts";
|
|
6
6
|
import { isFinishedRunStatus } from "./process-status.ts";
|
|
7
7
|
|
|
8
|
+
export interface RunWaitResult {
|
|
9
|
+
manifest: TeamRunManifest;
|
|
10
|
+
tasks: TeamTaskState[];
|
|
11
|
+
/** True when the waiter was released early by `detachRunPromise` while the
|
|
12
|
+
* run itself keeps executing (see that function). */
|
|
13
|
+
detached?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
8
16
|
export interface ActiveRunPromise {
|
|
9
|
-
promise: Promise<
|
|
10
|
-
resolve: (value:
|
|
17
|
+
promise: Promise<RunWaitResult>;
|
|
18
|
+
resolve: (value: RunWaitResult) => void;
|
|
11
19
|
reject: (reason: unknown) => void;
|
|
12
20
|
}
|
|
13
21
|
|
|
14
22
|
const activeRunPromises = new Map<string, ActiveRunPromise>();
|
|
15
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Runs whose waiter should be released on its next opportunity.
|
|
26
|
+
*
|
|
27
|
+
* A detach request can arrive BEFORE `executeTeamRun` registers its foreground
|
|
28
|
+
* promise (the tool starts the run and calls `waitForRun` immediately, so a
|
|
29
|
+
* slow-starting run puts the waiter on the polling path with no promise to
|
|
30
|
+
* resolve). The flag makes the request path-independent: the polling loop
|
|
31
|
+
* consumes it just like `detachRunPromise` resolves a registered promise.
|
|
32
|
+
*/
|
|
33
|
+
const detachRequests = new Set<string>();
|
|
34
|
+
|
|
16
35
|
export function registerRunPromise(runId: string): ActiveRunPromise {
|
|
17
|
-
|
|
36
|
+
detachRequests.delete(runId);
|
|
37
|
+
let resolve!: (value: RunWaitResult) => void;
|
|
18
38
|
let reject!: (reason: unknown) => void;
|
|
19
|
-
const promise = new Promise<{
|
|
20
|
-
manifest: TeamRunManifest;
|
|
21
|
-
tasks: TeamTaskState[];
|
|
22
|
-
}>((res, rej) => {
|
|
39
|
+
const promise = new Promise<RunWaitResult>((res, rej) => {
|
|
23
40
|
resolve = res;
|
|
24
41
|
reject = rej;
|
|
25
42
|
});
|
|
@@ -28,7 +45,41 @@ export function registerRunPromise(runId: string): ActiveRunPromise {
|
|
|
28
45
|
return entry;
|
|
29
46
|
}
|
|
30
47
|
|
|
31
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Release a foreground waiter WITHOUT stopping the run.
|
|
50
|
+
*
|
|
51
|
+
* A foreground `team run` keeps the parent pi turn streaming for the whole run,
|
|
52
|
+
* and pi's `switchSession` tears the current session down via
|
|
53
|
+
* `session.abort()` → `waitForIdle()`. Opening an agent view therefore hung
|
|
54
|
+
* silently until the run finished. Detaching resolves the tool's `waitForRun`
|
|
55
|
+
* with the run's current (still-running) state so the tool call returns, the
|
|
56
|
+
* parent turn can settle, and the session switch lands — `executeTeamRun`
|
|
57
|
+
* itself keeps going in this process and the async notifier reports completion.
|
|
58
|
+
*
|
|
59
|
+
* Returns false only when the run's state cannot be read at all. When no
|
|
60
|
+
* foreground promise is registered yet (or the waiter is on the polling path),
|
|
61
|
+
* the request is recorded and consumed by `waitForRun` on its next poll.
|
|
62
|
+
*/
|
|
63
|
+
export function detachRunPromise(runId: string, cwd: string): boolean {
|
|
64
|
+
const loaded = loadRunManifestById(cwd, runId);
|
|
65
|
+
if (!loaded) return false;
|
|
66
|
+
const entry = activeRunPromises.get(runId);
|
|
67
|
+
if (entry) {
|
|
68
|
+
activeRunPromises.delete(runId);
|
|
69
|
+
detachRequests.delete(runId);
|
|
70
|
+
entry.resolve({ ...loaded, detached: true });
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
detachRequests.add(runId);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** True while a detach was requested but no waiter has consumed it yet. */
|
|
78
|
+
export function hasPendingRunDetach(runId: string): boolean {
|
|
79
|
+
return detachRequests.has(runId);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function resolveRunPromise(runId: string, result: RunWaitResult): void {
|
|
32
83
|
const entry = activeRunPromises.get(runId);
|
|
33
84
|
if (entry) {
|
|
34
85
|
entry.resolve(result);
|
|
@@ -54,15 +105,22 @@ export async function waitForRun(
|
|
|
54
105
|
runId: string,
|
|
55
106
|
cwd: string,
|
|
56
107
|
options: { timeoutMs?: number; pollIntervalMs?: number } = {},
|
|
57
|
-
): Promise<
|
|
108
|
+
): Promise<RunWaitResult> {
|
|
58
109
|
const { timeoutMs = 300_000, pollIntervalMs = 500 } = options;
|
|
59
110
|
const deadline = Date.now() + timeoutMs;
|
|
60
111
|
|
|
112
|
+
// A detach requested before this waiter existed applies to it (and is
|
|
113
|
+
// consumed here, so it can never leak onto a later waiter for the same run).
|
|
114
|
+
const detachPending = detachRequests.delete(runId);
|
|
115
|
+
|
|
61
116
|
// Fast path: already terminal on disk
|
|
62
117
|
const loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
|
|
63
118
|
if (loaded && isFinishedRunStatus(loaded.manifest.status)) {
|
|
64
119
|
return loaded;
|
|
65
120
|
}
|
|
121
|
+
if (detachPending && loaded) {
|
|
122
|
+
return { ...loaded, detached: true };
|
|
123
|
+
}
|
|
66
124
|
|
|
67
125
|
// Medium path: foreground promise registered in this process
|
|
68
126
|
const entry = activeRunPromises.get(runId);
|
|
@@ -78,9 +136,17 @@ export async function waitForRun(
|
|
|
78
136
|
}
|
|
79
137
|
}
|
|
80
138
|
|
|
81
|
-
// Slow path: background run — poll with exponential backoff capped at pollIntervalMs
|
|
139
|
+
// Slow path: background run — poll with exponential backoff capped at pollIntervalMs.
|
|
140
|
+
// This path is ALSO taken by a foreground run whose executeTeamRun has not
|
|
141
|
+
// registered its promise yet (the tool calls waitForRun immediately after
|
|
142
|
+
// starting the run), so it must honour a pending detach request too —
|
|
143
|
+
// otherwise opening an agent view can never release the parent turn.
|
|
82
144
|
let attempt = 0;
|
|
83
145
|
while (Date.now() < deadline) {
|
|
146
|
+
if (detachRequests.delete(runId)) {
|
|
147
|
+
const current = loadRunManifestById(cwd, runId);
|
|
148
|
+
if (current) return { ...current, detached: true };
|
|
149
|
+
}
|
|
84
150
|
if (attempt === 0) {
|
|
85
151
|
// Early exit: if the run directory doesn't exist, don't waste time polling.
|
|
86
152
|
// Use projectCrewRoot() to honour the .pi/teams/ fallback for .pi-based
|
|
@@ -109,6 +175,7 @@ export function hasActiveRunPromise(runId: string): boolean {
|
|
|
109
175
|
}
|
|
110
176
|
|
|
111
177
|
export function clearRunPromisesForTest(): void {
|
|
178
|
+
detachRequests.clear();
|
|
112
179
|
for (const entry of activeRunPromises.values()) {
|
|
113
180
|
entry.reject(new Error("Cleared by test"));
|
|
114
181
|
}
|
|
@@ -258,7 +258,7 @@ export {
|
|
|
258
258
|
// Re-export the test-only helpers so existing test imports still resolve.
|
|
259
259
|
export { __test__mergeTaskUpdates, __test__shouldMergeTaskUpdate } from "./merge-gate.ts";
|
|
260
260
|
|
|
261
|
-
import { injectAdaptivePlanIfReady } from "./goal-workflow/adaptive-plan.ts";
|
|
261
|
+
import { injectAdaptivePlanIfReady, isAdaptiveWorkflow } from "./goal-workflow/adaptive-plan.ts";
|
|
262
262
|
|
|
263
263
|
// formatTaskProgress / runEffectivenessLines / scratchpadSummaryLines /
|
|
264
264
|
// lastProgressContentHash / writeProgress moved to ./finalize-run.ts (2026-08
|
|
@@ -769,7 +769,7 @@ async function executeTeamRunCore(
|
|
|
769
769
|
}
|
|
770
770
|
let tasks = refreshTaskGraphQueues(input.tasks);
|
|
771
771
|
let queueIndex = buildTaskGraphIndex(tasks);
|
|
772
|
-
const canInjectAdaptivePlan = workflow
|
|
772
|
+
const canInjectAdaptivePlan = isAdaptiveWorkflow(workflow);
|
|
773
773
|
let adaptivePlanInjected = false;
|
|
774
774
|
let adaptivePlanMissing = false;
|
|
775
775
|
const attemptAdaptivePlan = async () => {
|
|
@@ -779,6 +779,7 @@ async function executeTeamRunCore(
|
|
|
779
779
|
tasks,
|
|
780
780
|
workflow,
|
|
781
781
|
team: input.team,
|
|
782
|
+
executeWorkers: input.executeWorkers,
|
|
782
783
|
});
|
|
783
784
|
adaptivePlanInjected = adaptivePlanInjected || adaptivePlan.injected;
|
|
784
785
|
adaptivePlanMissing = adaptivePlan.missingPlan;
|
|
@@ -282,7 +282,7 @@ export const PiTeamsOtlpConfigSchema = Type.Object(
|
|
|
282
282
|
|
|
283
283
|
export const PiTeamsUiConfigSchema = Type.Object(
|
|
284
284
|
{
|
|
285
|
-
widgetPlacement: Type.Optional(Type.Union([Type.Literal("aboveEditor"), Type.Literal("belowEditor")])),
|
|
285
|
+
widgetPlacement: Type.Optional(Type.Union([Type.Literal("aboveEditor"), Type.Literal("belowEditor"), Type.Literal("bottom")])),
|
|
286
286
|
widgetMaxLines: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 })),
|
|
287
287
|
powerbar: Type.Optional(Type.Boolean()),
|
|
288
288
|
dashboardPlacement: Type.Optional(Type.Union([Type.Literal("center"), Type.Literal("right")])),
|
|
@@ -50,7 +50,7 @@ export function loadPlanRecords(manifest: TeamRunManifest): PlanRecord[] {
|
|
|
50
50
|
const raw = fs.readFileSync(planFilePath(manifest), "utf-8");
|
|
51
51
|
const parsed = JSON.parse(raw) as Partial<PlanFile>;
|
|
52
52
|
if (!parsed || !Array.isArray(parsed.revisions)) return [];
|
|
53
|
-
return parsed.revisions.filter((r): r is PlanRecord => Boolean(r
|
|
53
|
+
return parsed.revisions.filter((r): r is PlanRecord => Boolean(r?.id && typeof r.version === "number"));
|
|
54
54
|
} catch (err) {
|
|
55
55
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
56
56
|
logInternalError("plan-store.read-failed", err instanceof Error ? err : new Error(String(err)), `run=${manifest.runId}`);
|
|
@@ -260,7 +260,42 @@ export function createRunPaths(cwd: string, runId = createRunId()): RunPaths {
|
|
|
260
260
|
};
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Trailing phrases a step template leaves in front of `{goal}` — "Explore
|
|
265
|
+
* the codebase for the goal: {goal}" should name the task ("Explore the
|
|
266
|
+
* codebase"), not quote the whole run goal after every verb. The run goal is
|
|
267
|
+
* run-level context (statusline shows it once); a task list that repeats it
|
|
268
|
+
* per row reads like a roster of workers, not a list of work.
|
|
269
|
+
*/
|
|
270
|
+
const GOAL_TAILS = ["for the goal", "for goal", "the goal", "goal", "cho mục tiêu", "của mục tiêu", "mục tiêu", "for", "of", "cho", "của"];
|
|
271
|
+
|
|
272
|
+
function stripGoalTail(prefix: string): string {
|
|
273
|
+
let name = prefix.replace(/[\s:—-]+$/, "").trim();
|
|
274
|
+
let changed = true;
|
|
275
|
+
while (changed) {
|
|
276
|
+
changed = false;
|
|
277
|
+
const lowered = name.toLowerCase();
|
|
278
|
+
for (const tail of GOAL_TAILS) {
|
|
279
|
+
if (lowered.endsWith(tail) && lowered.length > tail.length) {
|
|
280
|
+
name = name
|
|
281
|
+
.slice(0, name.length - tail.length)
|
|
282
|
+
.replace(/[\s:—-]+$/, "")
|
|
283
|
+
.trim();
|
|
284
|
+
changed = true;
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return name;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function createTasksFromWorkflow(
|
|
293
|
+
runId: string,
|
|
294
|
+
workflow: WorkflowConfig,
|
|
295
|
+
team: TeamConfig,
|
|
296
|
+
cwd: string,
|
|
297
|
+
goal?: string,
|
|
298
|
+
): TeamTaskState[] {
|
|
264
299
|
const stepToTaskId = new Map(workflow.steps.map((step, index) => [step.id, createTaskId(step.id, index)]));
|
|
265
300
|
return workflow.steps.map((step, index) => {
|
|
266
301
|
const role = team.roles.find((candidate) => candidate.name === step.role);
|
|
@@ -270,13 +305,48 @@ export function createTasksFromWorkflow(runId: string, workflow: WorkflowConfig,
|
|
|
270
305
|
.filter((candidate) => candidate.dependsOn?.includes(step.id))
|
|
271
306
|
.map((candidate) => stepToTaskId.get(candidate.id))
|
|
272
307
|
.filter((childId): childId is string => childId !== undefined);
|
|
308
|
+
// The plan's own words become the task's display identity: a heading
|
|
309
|
+
// from the step body if present, else its first content line; the
|
|
310
|
+
// remaining body is kept as `description` for detail surfaces (task
|
|
311
|
+
// list detail line, view). Falls back to the step id when the body
|
|
312
|
+
// carries no text of its own — `task` is optional on literal
|
|
313
|
+
// WorkflowStep objects.
|
|
314
|
+
//
|
|
315
|
+
// `{goal}` in the title line is the RUN's goal, context for every
|
|
316
|
+
// task, not this task's name. The template prefix before the
|
|
317
|
+
// placeholder names the task; a placeholder-only body ("{goal}",
|
|
318
|
+
// direct-run) adopts the goal itself as the title. `description`
|
|
319
|
+
// lines substitute the placeholder (the prompt path substitutes
|
|
320
|
+
// separately — unchanged).
|
|
321
|
+
const bodyLines =
|
|
322
|
+
typeof step.task === "string" && step.task.trim()
|
|
323
|
+
? step.task
|
|
324
|
+
.split("\n")
|
|
325
|
+
.map((line) => line.trim())
|
|
326
|
+
.filter(Boolean)
|
|
327
|
+
: [];
|
|
328
|
+
const heading = bodyLines.find((line) => line.startsWith("#"));
|
|
329
|
+
const titleLine = heading ?? bodyLines[0];
|
|
330
|
+
const substitute = (text: string): string => (goal ? text.replaceAll("{goal}", goal) : text);
|
|
331
|
+
let title: string;
|
|
332
|
+
let restLines: string[];
|
|
333
|
+
const placeholderAt = titleLine?.indexOf("{goal}") ?? -1;
|
|
334
|
+
if (titleLine && placeholderAt >= 0) {
|
|
335
|
+
title = stripGoalTail(titleLine.slice(0, placeholderAt)) || goal || step.id;
|
|
336
|
+
restLines = bodyLines.filter((line) => line !== titleLine);
|
|
337
|
+
} else {
|
|
338
|
+
title = (titleLine ?? step.id).replace(/^#+\s*/, "");
|
|
339
|
+
restLines = heading ? bodyLines.filter((line) => line !== titleLine) : bodyLines.slice(1);
|
|
340
|
+
}
|
|
341
|
+
const description = restLines.map(substitute).join("\n");
|
|
273
342
|
return {
|
|
274
343
|
id,
|
|
275
344
|
runId,
|
|
276
345
|
stepId: step.id,
|
|
277
346
|
role: step.role,
|
|
278
347
|
agent: role?.agent ?? step.role,
|
|
279
|
-
title:
|
|
348
|
+
title: title.slice(0, 120),
|
|
349
|
+
description: description && description !== title ? description : undefined,
|
|
280
350
|
status: "queued",
|
|
281
351
|
dependsOn: dependencies,
|
|
282
352
|
cwd,
|
|
@@ -305,7 +375,7 @@ export function createRunManifest(params: {
|
|
|
305
375
|
}): { manifest: TeamRunManifest; tasks: TeamTaskState[]; paths: RunPaths } {
|
|
306
376
|
const paths = createRunPaths(params.cwd);
|
|
307
377
|
const now = new Date().toISOString();
|
|
308
|
-
const tasks = params.workflow ? createTasksFromWorkflow(paths.runId, params.workflow, params.team, params.cwd) : [];
|
|
378
|
+
const tasks = params.workflow ? createTasksFromWorkflow(paths.runId, params.workflow, params.team, params.cwd, params.goal) : [];
|
|
309
379
|
const manifest: TeamRunManifest = {
|
|
310
380
|
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
311
381
|
runId: paths.runId,
|
package/src/state/types.ts
CHANGED
|
@@ -554,6 +554,8 @@ export interface TeamTaskState {
|
|
|
554
554
|
role: string;
|
|
555
555
|
agent: string;
|
|
556
556
|
title: string;
|
|
557
|
+
/** Full step body (the detailed plan text behind `title`). */
|
|
558
|
+
description?: string;
|
|
557
559
|
displayName?: string;
|
|
558
560
|
status: TeamTaskStatus;
|
|
559
561
|
dependsOn: string[];
|