pi-long-task 0.6.0 → 0.7.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/CHANGELOG.md +22 -0
- package/README.md +72 -5
- package/package.json +1 -1
- package/src/coordinator.ts +433 -50
- package/src/goal_orchestrator.ts +11 -0
- package/src/goal_todo_execution.ts +4 -0
- package/src/goal_todo_generation.ts +12 -10
- package/src/index.ts +13 -1
- package/src/network_recovery.ts +2 -2
- package/src/planner_config.ts +214 -0
- package/src/planner_progress.ts +156 -0
- package/src/render.ts +36 -0
- package/src/session_guard.ts +113 -7
- package/src/todo_generator.ts +82 -5
- package/src/types.ts +36 -0
- package/src/worker_capabilities.ts +103 -0
- package/src/worker_config.ts +74 -7
package/src/goal_orchestrator.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type NetworkRecoveryConfig,
|
|
31
31
|
type NetworkRecoveryConfigInput,
|
|
32
32
|
} from "./network_recovery_config.ts";
|
|
33
|
+
import { validatePlannerGracefulShutdownMs, validatePlannerTimeoutMs } from "./planner_config.ts";
|
|
33
34
|
import { runGoalReviewSession, type GoalReviewResult, type GoalReviewerRunner } from "./goal_review.ts";
|
|
34
35
|
import {
|
|
35
36
|
runGoalTodoExecutionLongTask,
|
|
@@ -109,6 +110,8 @@ export interface RunGoalLoopOptions extends GoalLoopLimitInput {
|
|
|
109
110
|
thinkingLevel?: string;
|
|
110
111
|
maxBashTimeoutMs?: number;
|
|
111
112
|
maxAttemptsPerTask?: number;
|
|
113
|
+
todoTimeoutMs?: number;
|
|
114
|
+
todoGracefulShutdownMs?: number;
|
|
112
115
|
networkRecovery?: NetworkRecoveryConfigInput;
|
|
113
116
|
commit?: boolean;
|
|
114
117
|
now?: () => Date;
|
|
@@ -140,6 +143,8 @@ export class GoalLoopOrchestratorError extends Error {
|
|
|
140
143
|
export async function runGoalLoop(options: RunGoalLoopOptions): Promise<GoalLoopRunResult> {
|
|
141
144
|
const now = options.now ?? (() => new Date());
|
|
142
145
|
const networkRecovery = resolveNetworkRecoveryConfig(options.networkRecovery);
|
|
146
|
+
const todoTimeoutMs = validatePlannerTimeoutMs(options.todoTimeoutMs);
|
|
147
|
+
const todoGracefulShutdownMs = validatePlannerGracefulShutdownMs(options.todoGracefulShutdownMs);
|
|
143
148
|
let state =
|
|
144
149
|
options.initialState ??
|
|
145
150
|
createGoalLoopState({
|
|
@@ -282,6 +287,8 @@ export async function runGoalLoop(options: RunGoalLoopOptions): Promise<GoalLoop
|
|
|
282
287
|
modelName: options.modelName,
|
|
283
288
|
thinkingLevel: options.thinkingLevel,
|
|
284
289
|
maxBashTimeoutMs: options.maxBashTimeoutMs,
|
|
290
|
+
todoTimeoutMs,
|
|
291
|
+
todoGracefulShutdownMs,
|
|
285
292
|
networkRecovery,
|
|
286
293
|
now,
|
|
287
294
|
goalSpecification,
|
|
@@ -313,6 +320,8 @@ export async function runGoalLoop(options: RunGoalLoopOptions): Promise<GoalLoop
|
|
|
313
320
|
modelName: options.modelName,
|
|
314
321
|
thinkingLevel: options.thinkingLevel,
|
|
315
322
|
maxBashTimeoutMs: options.maxBashTimeoutMs,
|
|
323
|
+
todoTimeoutMs,
|
|
324
|
+
todoGracefulShutdownMs,
|
|
316
325
|
networkRecovery,
|
|
317
326
|
now,
|
|
318
327
|
goalSpecification,
|
|
@@ -346,6 +355,8 @@ export async function runGoalLoop(options: RunGoalLoopOptions): Promise<GoalLoop
|
|
|
346
355
|
thinkingLevel: options.thinkingLevel,
|
|
347
356
|
maxBashTimeoutMs: options.maxBashTimeoutMs,
|
|
348
357
|
maxAttemptsPerTask: options.maxAttemptsPerTask,
|
|
358
|
+
todoTimeoutMs,
|
|
359
|
+
todoGracefulShutdownMs,
|
|
349
360
|
networkRecovery,
|
|
350
361
|
commit: options.commit,
|
|
351
362
|
now,
|
|
@@ -33,6 +33,8 @@ export interface GoalTodoExecutionOptions {
|
|
|
33
33
|
thinkingLevel?: string;
|
|
34
34
|
maxBashTimeoutMs?: number;
|
|
35
35
|
maxAttemptsPerTask?: number;
|
|
36
|
+
todoTimeoutMs?: number;
|
|
37
|
+
todoGracefulShutdownMs?: number;
|
|
36
38
|
networkRecovery?: Readonly<NetworkRecoveryConfig>;
|
|
37
39
|
commit?: boolean;
|
|
38
40
|
now?: () => Date;
|
|
@@ -154,6 +156,8 @@ export async function runGoalTodoExecutionLongTask(
|
|
|
154
156
|
taskTimeoutMs: childTimeoutMs,
|
|
155
157
|
maxBashTimeoutMs: options.maxBashTimeoutMs,
|
|
156
158
|
maxAttemptsPerTask: options.maxAttemptsPerTask,
|
|
159
|
+
todoTimeoutMs: options.todoTimeoutMs,
|
|
160
|
+
todoGracefulShutdownMs: options.todoGracefulShutdownMs,
|
|
157
161
|
networkRecovery: options.networkRecovery,
|
|
158
162
|
onProgress: (update) => {
|
|
159
163
|
progressEvents.push(update);
|
|
@@ -19,6 +19,7 @@ import { parseTasks } from "./todo_parser.ts";
|
|
|
19
19
|
import {
|
|
20
20
|
applyGoalInstructionsToTodoMarkdown,
|
|
21
21
|
extractAndValidateTodoMarkdown,
|
|
22
|
+
todoPlanningOnlyPromptBlock,
|
|
22
23
|
validateTodoMarkdown,
|
|
23
24
|
} from "./todo_generator.ts";
|
|
24
25
|
|
|
@@ -38,6 +39,8 @@ export interface GoalTodoGenerationOptions {
|
|
|
38
39
|
modelName?: string;
|
|
39
40
|
thinkingLevel?: string;
|
|
40
41
|
maxBashTimeoutMs?: number;
|
|
42
|
+
todoTimeoutMs?: number;
|
|
43
|
+
todoGracefulShutdownMs?: number;
|
|
41
44
|
networkRecovery?: Readonly<NetworkRecoveryConfig>;
|
|
42
45
|
now?: () => Date;
|
|
43
46
|
additionalContext?: string;
|
|
@@ -122,6 +125,8 @@ export async function runGoalTodoGenerationLongTask(
|
|
|
122
125
|
taskThinking: options.thinkingLevel,
|
|
123
126
|
taskTimeoutMs: childTimeoutMs,
|
|
124
127
|
maxBashTimeoutMs: options.maxBashTimeoutMs,
|
|
128
|
+
todoTimeoutMs: options.todoTimeoutMs,
|
|
129
|
+
todoGracefulShutdownMs: options.todoGracefulShutdownMs,
|
|
125
130
|
networkRecovery: options.networkRecovery,
|
|
126
131
|
onNetworkRecovery: captureOutage,
|
|
127
132
|
};
|
|
@@ -225,16 +230,17 @@ export function buildGoalTodoGenerationTaskPayload(options: {
|
|
|
225
230
|
|
|
226
231
|
- Long task goal: ${oneLine(options.state.goal)}
|
|
227
232
|
- This is goal-loop TODO generation iteration ${options.iteration} for goal run ${options.state.goalRunId}.
|
|
228
|
-
- Only generate TODO markdown for future workers; do not implement, edit, test, refactor, or otherwise perform the goal work in this generation run.
|
|
229
233
|
- Write the generated Pi Long Task-compatible TODO markdown to \`${options.outputPath}\`.
|
|
230
234
|
- Do not wrap the generated file in a code fence and do not include commentary outside the TODO markdown in that file.
|
|
231
235
|
- Keep generated tasks focused, independently assignable, and safe for separate worker sessions.
|
|
232
236
|
${iterationPolicy}
|
|
233
237
|
${
|
|
234
238
|
options.goalSpecification
|
|
235
|
-
? "- A persisted goal specification is available; derive implementation TODOs from
|
|
239
|
+
? "- A persisted goal specification is available; derive implementation TODOs from it rather than only the original vague goal.\n- Preserve its requirement IDs, milestones, acceptance criteria, verification gates, constraints, and definition of done in the relevant tasks. Reference each applicable spec ID (for example REQ-*, MS-*, AC-*, VG-*) where it is most useful instead of duplicating it in every task field.\n"
|
|
236
240
|
: ""
|
|
237
241
|
}
|
|
242
|
+
${todoPlanningOnlyPromptBlock()}
|
|
243
|
+
|
|
238
244
|
## Progress
|
|
239
245
|
|
|
240
246
|
- [ ] TODO 1 — Generate Pi Long Task TODO markdown
|
|
@@ -246,16 +252,12 @@ ${
|
|
|
246
252
|
**Goal:** Convert the high-level goal into a valid Pi Long Task TODO plan for the next implementation long task.
|
|
247
253
|
|
|
248
254
|
**Status:**
|
|
249
|
-
- [ ]
|
|
250
|
-
- [ ]
|
|
251
|
-
- [ ]
|
|
252
|
-
- [ ] Include a \`---\` separator before generated task sections.
|
|
253
|
-
- [ ] Include sequential \`## TODO N — Title\` sections with \`**Goal:**\`, \`**Status:**\`, \`**Verify:**\`, and \`**Done when:**\` guidance.
|
|
254
|
-
- [ ] Write only the generated TODO markdown to \`${options.outputPath}\`.
|
|
255
|
+
- [ ] Derive focused future-worker tasks from the goal, constraints, and iteration context without performing that work.
|
|
256
|
+
- [ ] Render the required Progress list and sequential task sections with Goal, Status, Verify, and Done when fields.
|
|
257
|
+
- [ ] Write only the concise generated TODO markdown to \`${options.outputPath}\`.
|
|
255
258
|
|
|
256
259
|
**Verify:**
|
|
257
|
-
- Confirm
|
|
258
|
-
- Confirm it starts with \`# Pi Long Task TODO\`, has a \`## Progress\` section, a \`---\` separator, sequential TODO sections, unchecked status checkboxes, and concrete verification instructions.
|
|
260
|
+
- Confirm \`${options.outputPath}\` contains valid TODO markdown with preserved constraints, compact task sections, unchecked statuses, and concrete checks.
|
|
259
261
|
|
|
260
262
|
**Done when:**
|
|
261
263
|
- \`${options.outputPath}\` contains valid Pi Long Task-compatible TODO markdown for achieving the high-level goal.
|
package/src/index.ts
CHANGED
|
@@ -80,6 +80,8 @@ function toolDetails(result: CoordinatorResult) {
|
|
|
80
80
|
remainingTasks: result.remainingTasks,
|
|
81
81
|
taskProgress: result.taskProgress,
|
|
82
82
|
workerCostTotal: result.workerCostTotal,
|
|
83
|
+
plannerBudget: result.plannerBudget,
|
|
84
|
+
capabilityWarnings: result.capabilityWarnings,
|
|
83
85
|
summary: result.summary,
|
|
84
86
|
goal: result.goal,
|
|
85
87
|
error: result.error,
|
|
@@ -349,7 +351,15 @@ function renderSidebarRows(update: CoordinatorProgressUpdate | undefined, theme:
|
|
|
349
351
|
rows.push(renderSidebarStateLine(update, theme));
|
|
350
352
|
|
|
351
353
|
if (!progress || progress.tasks.length === 0) {
|
|
352
|
-
|
|
354
|
+
const planningStatus =
|
|
355
|
+
update.phase === "planning" ? normalizeActiveStatus(update.activeStatus ?? update.message) : undefined;
|
|
356
|
+
rows.push(
|
|
357
|
+
"",
|
|
358
|
+
sidebarHeading(planningStatus ? "Planning timing" : "Context", theme),
|
|
359
|
+
...(planningStatus
|
|
360
|
+
? wrapPlainText(planningStatus, width, 8).map((line) => theme.fg("accent", line))
|
|
361
|
+
: [theme.fg("muted", "Waiting for TODO plan")]),
|
|
362
|
+
);
|
|
353
363
|
if (update.workerCostTotal > 0) {
|
|
354
364
|
rows.push(theme.fg("muted", `${formatCost(update.workerCostTotal)} spent`));
|
|
355
365
|
}
|
|
@@ -566,6 +576,8 @@ function sidebarUpdateStateDetails(update: CoordinatorProgressUpdate): {
|
|
|
566
576
|
}
|
|
567
577
|
|
|
568
578
|
switch (update.phase) {
|
|
579
|
+
case "capability_warning":
|
|
580
|
+
return { icon: "!", label: "Capability warning", color: "warning" };
|
|
569
581
|
case "planning":
|
|
570
582
|
return { icon: "+", label: "Planning", color: "warning" };
|
|
571
583
|
case "planned":
|
package/src/network_recovery.ts
CHANGED
|
@@ -91,9 +91,9 @@ export function formatNetworkRecoveryStatus(event: NetworkRecoveryEvent): string
|
|
|
91
91
|
|
|
92
92
|
function formatRecoveryDuration(milliseconds: number): string {
|
|
93
93
|
const safeMs = Math.max(0, Math.round(milliseconds));
|
|
94
|
-
if (safeMs < 1_000) return
|
|
94
|
+
if (safeMs > 0 && safeMs < 1_000) return "less than 1 second";
|
|
95
95
|
const seconds = safeMs / 1_000;
|
|
96
|
-
return `${Number.isInteger(seconds) ? seconds : seconds.toFixed(1)}
|
|
96
|
+
return `${Number.isInteger(seconds) ? seconds : seconds.toFixed(1)} seconds`;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
export class NetworkOutageExpiredError extends Error {
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
export const MAX_PLANNER_DURATION_MS = 2_147_483_647;
|
|
2
|
+
|
|
3
|
+
/** Thinking levels accepted by the supported Pi SDK, in increasing reasoning-budget order. */
|
|
4
|
+
export const SUPPORTED_PLANNER_THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
5
|
+
export type PlannerThinkingLevel = (typeof SUPPORTED_PLANNER_THINKING_LEVELS)[number];
|
|
6
|
+
/**
|
|
7
|
+
* Planner-only quality/latency balance. `high` retains enough reasoning budget
|
|
8
|
+
* for dependency-aware complex plans without imposing `xhigh` latency on every
|
|
9
|
+
* ordinary request. Explicit caller values remain authoritative.
|
|
10
|
+
*/
|
|
11
|
+
export const DEFAULT_PLANNER_THINKING_LEVEL: PlannerThinkingLevel = "high";
|
|
12
|
+
|
|
13
|
+
/** Normal planning budget used for requests that do not contain a scale signal. */
|
|
14
|
+
export const DEFAULT_PLANNER_TIMEOUT_MS = 300_000;
|
|
15
|
+
/** Adaptive planning never reduces the normal five-minute budget. */
|
|
16
|
+
export const MIN_ADAPTIVE_PLANNER_TIMEOUT_MS = DEFAULT_PLANNER_TIMEOUT_MS;
|
|
17
|
+
/** Adaptive planning is capped at fifteen minutes, even for very large requests. */
|
|
18
|
+
export const MAX_ADAPTIVE_PLANNER_TIMEOUT_MS = 900_000;
|
|
19
|
+
/** The normal budget includes up to four requested deliverables. */
|
|
20
|
+
export const PLANNER_ITEMS_INCLUDED_IN_BASE_BUDGET = 4;
|
|
21
|
+
/** Every additional detected deliverable adds thirty seconds until the cap. */
|
|
22
|
+
export const PLANNER_TIMEOUT_PER_ADDITIONAL_ITEM_MS = 30_000;
|
|
23
|
+
|
|
24
|
+
export type PlannerComplexitySignalKind =
|
|
25
|
+
| "explicit_item_count"
|
|
26
|
+
| "enumerated_deliverables"
|
|
27
|
+
| "separately_planned_tasks";
|
|
28
|
+
|
|
29
|
+
export interface PlannerComplexitySignal {
|
|
30
|
+
kind: PlannerComplexitySignalKind;
|
|
31
|
+
itemCount: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type PlannerBudgetSource = "explicit" | "default" | "adaptive";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Complete, machine-readable record of how a planner deadline was selected.
|
|
38
|
+
* `trigger` is present only when a deterministic complexity signal actually
|
|
39
|
+
* extended the normal budget.
|
|
40
|
+
*/
|
|
41
|
+
export interface PlannerBudget {
|
|
42
|
+
timeoutMs: number;
|
|
43
|
+
baseTimeoutMs: number;
|
|
44
|
+
minimumTimeoutMs: number;
|
|
45
|
+
maximumTimeoutMs: number;
|
|
46
|
+
extensionApplied: boolean;
|
|
47
|
+
extensionMs: number;
|
|
48
|
+
source: PlannerBudgetSource;
|
|
49
|
+
signals: readonly PlannerComplexitySignal[];
|
|
50
|
+
trigger?: PlannerComplexitySignal;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ResolvePlannerBudgetOptions {
|
|
54
|
+
inputText: string;
|
|
55
|
+
/** Structured or natural-language timeout configuration. It always wins exactly. */
|
|
56
|
+
explicitTimeoutMs?: number;
|
|
57
|
+
defaultTimeoutMs?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const EXPLICIT_ITEM_COUNT_RE =
|
|
61
|
+
/\b(\d{1,6})\s+(?:(?:separate(?:ly)?|individual(?:ly)?|distinct|independent(?:ly)?)\s+(?:planned\s+)?)?(?:(?:user|job|work)\s+)?(?:stories|tasks|todos?|deliverables?|items?|work\s+items?|features?|components?|pages?|endpoints?|tests?|scenarios?|requirements?)\b/gi;
|
|
62
|
+
const ENUMERATED_DELIVERABLE_RE = /^\s*(?:[-*+]\s+(?:\[[ xX]\]\s+)?|\d{1,6}[.)]\s+)\S.*$/gm;
|
|
63
|
+
const SEPARATE_PLANNING_RE =
|
|
64
|
+
/\b(?:separately|individually|independently)\s+(?:plan(?:ned)?|scope(?:d)?|specif(?:y|ied)|assign(?:ed)?)\b|\b(?:plan|scope|specify)\s+(?:each|every)\b|\b(?:separate|individual|independent)\s+(?:plans?|tasks?|todos?|work\s+items?)\b/i;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Detects only reproducible textual scale signals:
|
|
68
|
+
*
|
|
69
|
+
* 1. an integer directly attached to a deliverable noun (for example,
|
|
70
|
+
* "24 stories"),
|
|
71
|
+
* 2. line-start bullet or numbered deliverables, and
|
|
72
|
+
* 3. explicit language requiring those items to be planned separately.
|
|
73
|
+
*
|
|
74
|
+
* The detector intentionally does not estimate semantic difficulty or ask the
|
|
75
|
+
* model to grade complexity. Signals are returned in stable priority order.
|
|
76
|
+
*/
|
|
77
|
+
export function detectPlannerComplexitySignals(inputText: string): PlannerComplexitySignal[] {
|
|
78
|
+
const normalized = inputText.replace(/\r\n?/g, "\n");
|
|
79
|
+
const explicitItemCount = maximumMatchedInteger(normalized, EXPLICIT_ITEM_COUNT_RE);
|
|
80
|
+
const enumeratedItemCount = [...normalized.matchAll(ENUMERATED_DELIVERABLE_RE)].length;
|
|
81
|
+
const signals: PlannerComplexitySignal[] = [];
|
|
82
|
+
|
|
83
|
+
if (SEPARATE_PLANNING_RE.test(normalized)) {
|
|
84
|
+
const separatelyPlannedCount = Math.max(explicitItemCount ?? 0, enumeratedItemCount);
|
|
85
|
+
if (separatelyPlannedCount > 0) {
|
|
86
|
+
signals.push({ kind: "separately_planned_tasks", itemCount: separatelyPlannedCount });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (explicitItemCount !== undefined) {
|
|
90
|
+
signals.push({ kind: "explicit_item_count", itemCount: explicitItemCount });
|
|
91
|
+
}
|
|
92
|
+
if (enumeratedItemCount > 0) {
|
|
93
|
+
signals.push({ kind: "enumerated_deliverables", itemCount: enumeratedItemCount });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return signals;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Selects a deterministic planner deadline. The normal five-minute budget
|
|
101
|
+
* covers four items; each additional item adds thirty seconds, capped at
|
|
102
|
+
* fifteen minutes. Explicit timeout configuration bypasses both detection and
|
|
103
|
+
* adaptive bounds and is returned unchanged after public duration validation.
|
|
104
|
+
*/
|
|
105
|
+
export function resolvePlannerBudget(options: ResolvePlannerBudgetOptions): PlannerBudget {
|
|
106
|
+
const baseTimeoutMs = resolvePlannerTimeoutMs(options.defaultTimeoutMs, DEFAULT_PLANNER_TIMEOUT_MS);
|
|
107
|
+
|
|
108
|
+
if (options.explicitTimeoutMs !== undefined) {
|
|
109
|
+
const timeoutMs = resolvePlannerTimeoutMs(options.explicitTimeoutMs, baseTimeoutMs);
|
|
110
|
+
return {
|
|
111
|
+
timeoutMs,
|
|
112
|
+
baseTimeoutMs,
|
|
113
|
+
minimumTimeoutMs: MIN_ADAPTIVE_PLANNER_TIMEOUT_MS,
|
|
114
|
+
maximumTimeoutMs: MAX_ADAPTIVE_PLANNER_TIMEOUT_MS,
|
|
115
|
+
extensionApplied: false,
|
|
116
|
+
extensionMs: 0,
|
|
117
|
+
source: "explicit",
|
|
118
|
+
signals: [],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const signals = detectPlannerComplexitySignals(options.inputText);
|
|
123
|
+
const trigger = strongestComplexitySignal(signals);
|
|
124
|
+
const additionalItems = Math.max(0, (trigger?.itemCount ?? 0) - PLANNER_ITEMS_INCLUDED_IN_BASE_BUDGET);
|
|
125
|
+
const requestedTimeoutMs = baseTimeoutMs + additionalItems * PLANNER_TIMEOUT_PER_ADDITIONAL_ITEM_MS;
|
|
126
|
+
const timeoutMs = Math.min(
|
|
127
|
+
MAX_ADAPTIVE_PLANNER_TIMEOUT_MS,
|
|
128
|
+
Math.max(MIN_ADAPTIVE_PLANNER_TIMEOUT_MS, requestedTimeoutMs),
|
|
129
|
+
);
|
|
130
|
+
const extensionMs = Math.max(0, timeoutMs - baseTimeoutMs);
|
|
131
|
+
const extensionApplied = extensionMs > 0;
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
timeoutMs,
|
|
135
|
+
baseTimeoutMs,
|
|
136
|
+
minimumTimeoutMs: MIN_ADAPTIVE_PLANNER_TIMEOUT_MS,
|
|
137
|
+
maximumTimeoutMs: MAX_ADAPTIVE_PLANNER_TIMEOUT_MS,
|
|
138
|
+
extensionApplied,
|
|
139
|
+
extensionMs,
|
|
140
|
+
source: extensionApplied ? "adaptive" : "default",
|
|
141
|
+
signals,
|
|
142
|
+
...(extensionApplied && trigger ? { trigger } : {}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export class PlannerDurationConfigError extends Error {
|
|
147
|
+
constructor(message: string) {
|
|
148
|
+
super(message);
|
|
149
|
+
this.name = "PlannerDurationConfigError";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function resolvePlannerTimeoutMs(value: number | undefined, fallback: number): number {
|
|
154
|
+
return resolvePlannerDurationMs(value, fallback, "TODO planner timeout", false);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function resolvePlannerGracefulShutdownMs(value: number | undefined, fallback: number): number {
|
|
158
|
+
return resolvePlannerDurationMs(value, fallback, "TODO planner graceful-shutdown duration", true);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function validatePlannerTimeoutMs(value: number | undefined): number | undefined {
|
|
162
|
+
return value === undefined ? undefined : resolvePlannerTimeoutMs(value, value);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function validatePlannerGracefulShutdownMs(value: number | undefined): number | undefined {
|
|
166
|
+
return value === undefined ? undefined : resolvePlannerGracefulShutdownMs(value, value);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function maximumMatchedInteger(value: string, expression: RegExp): number | undefined {
|
|
170
|
+
expression.lastIndex = 0;
|
|
171
|
+
let maximum: number | undefined;
|
|
172
|
+
for (const match of value.matchAll(expression)) {
|
|
173
|
+
const count = Number.parseInt(match[1], 10);
|
|
174
|
+
if (Number.isSafeInteger(count) && count > 0) {
|
|
175
|
+
maximum = Math.max(maximum ?? 0, count);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return maximum;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function strongestComplexitySignal(signals: readonly PlannerComplexitySignal[]): PlannerComplexitySignal | undefined {
|
|
182
|
+
// Stable input order is also the tie-break priority: separate planning,
|
|
183
|
+
// explicit counts, then plain enumeration.
|
|
184
|
+
return signals.reduce<PlannerComplexitySignal | undefined>(
|
|
185
|
+
(strongest, signal) => (!strongest || signal.itemCount > strongest.itemCount ? signal : strongest),
|
|
186
|
+
undefined,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function resolvePlannerDurationMs(
|
|
191
|
+
value: number | undefined,
|
|
192
|
+
fallback: number,
|
|
193
|
+
label: string,
|
|
194
|
+
allowZero: boolean,
|
|
195
|
+
): number {
|
|
196
|
+
if (value === undefined) {
|
|
197
|
+
return fallback;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const minimumDescription = allowZero ? "a non-negative" : "a positive";
|
|
201
|
+
if (
|
|
202
|
+
typeof value !== "number" ||
|
|
203
|
+
!Number.isSafeInteger(value) ||
|
|
204
|
+
(!allowZero && value <= 0) ||
|
|
205
|
+
(allowZero && value < 0) ||
|
|
206
|
+
value > MAX_PLANNER_DURATION_MS
|
|
207
|
+
) {
|
|
208
|
+
throw new PlannerDurationConfigError(
|
|
209
|
+
`${label} must be ${minimumDescription} whole-millisecond duration no greater than about 24.9 days (${MAX_PLANNER_DURATION_MS} milliseconds); received ${String(value)}.`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { PlannerBudget, PlannerComplexitySignal } from "./planner_config.ts";
|
|
2
|
+
|
|
3
|
+
export type PlannerProgressState = "started" | "active" | "grace";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One user-facing planner timing update. Millisecond fields remain available to
|
|
7
|
+
* integrations while `message` consistently uses friendly duration wording.
|
|
8
|
+
*/
|
|
9
|
+
export interface PlannerProgressEvent {
|
|
10
|
+
state: PlannerProgressState;
|
|
11
|
+
message: string;
|
|
12
|
+
budgetMs: number;
|
|
13
|
+
gracePeriodMs: number;
|
|
14
|
+
elapsedMs: number;
|
|
15
|
+
remainingMs: number;
|
|
16
|
+
graceRemainingMs?: number;
|
|
17
|
+
budget: Readonly<PlannerBudget>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type PlannerProgressHandler = (event: Readonly<PlannerProgressEvent>) => void;
|
|
21
|
+
|
|
22
|
+
/** Formats a duration for people rather than exposing raw millisecond counts. */
|
|
23
|
+
export function formatFriendlyDuration(durationMs: number): string {
|
|
24
|
+
const milliseconds = Math.max(0, Number.isFinite(durationMs) ? durationMs : 0);
|
|
25
|
+
if (milliseconds > 0 && milliseconds < 1_000) {
|
|
26
|
+
return "less than 1 second";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const totalSeconds = Math.round(milliseconds / 1_000);
|
|
30
|
+
if (totalSeconds < 60) {
|
|
31
|
+
return plural(totalSeconds, "second");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
35
|
+
const seconds = totalSeconds % 60;
|
|
36
|
+
if (totalMinutes < 60) {
|
|
37
|
+
return joinDurationParts(plural(totalMinutes, "minute"), seconds > 0 ? plural(seconds, "second") : undefined);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const totalHours = Math.floor(totalMinutes / 60);
|
|
41
|
+
const minutes = totalMinutes % 60;
|
|
42
|
+
if (totalHours < 24) {
|
|
43
|
+
return joinDurationParts(plural(totalHours, "hour"), minutes > 0 ? plural(minutes, "minute") : undefined);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const days = Math.floor(totalHours / 24);
|
|
47
|
+
const hours = totalHours % 24;
|
|
48
|
+
return joinDurationParts(plural(days, "day"), hours > 0 ? plural(hours, "hour") : undefined);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createPlannerStartedProgress(
|
|
52
|
+
budget: Readonly<PlannerBudget>,
|
|
53
|
+
gracePeriodMs: number,
|
|
54
|
+
): PlannerProgressEvent {
|
|
55
|
+
const budgetText = formatFriendlyDuration(budget.timeoutMs);
|
|
56
|
+
const sourceText = budget.source === "explicit" ? " (explicitly configured)" : "";
|
|
57
|
+
const adaptiveText = adaptiveExtensionExplanation(budget);
|
|
58
|
+
const graceText =
|
|
59
|
+
gracePeriodMs > 0
|
|
60
|
+
? ` A ${formatFriendlyDurationModifier(gracePeriodMs)} graceful-shutdown period is available afterward.`
|
|
61
|
+
: " No graceful-shutdown period is configured.";
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
state: "started",
|
|
65
|
+
message: `Creating TODO plan. Effective planning budget: ${budgetText}${sourceText}.${adaptiveText}${graceText}`,
|
|
66
|
+
budgetMs: budget.timeoutMs,
|
|
67
|
+
gracePeriodMs,
|
|
68
|
+
elapsedMs: 0,
|
|
69
|
+
remainingMs: budget.timeoutMs,
|
|
70
|
+
budget,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createPlannerActiveProgress(
|
|
75
|
+
budget: Readonly<PlannerBudget>,
|
|
76
|
+
gracePeriodMs: number,
|
|
77
|
+
elapsedMs: number,
|
|
78
|
+
): PlannerProgressEvent {
|
|
79
|
+
const elapsed = clamp(elapsedMs, 0, budget.timeoutMs);
|
|
80
|
+
const remaining = Math.max(0, budget.timeoutMs - elapsed);
|
|
81
|
+
return {
|
|
82
|
+
state: "active",
|
|
83
|
+
message: `Still planning: ${formatFriendlyDuration(elapsed)} elapsed; about ${formatFriendlyDuration(remaining)} remaining in the ${formatFriendlyDuration(budget.timeoutMs)} budget.`,
|
|
84
|
+
budgetMs: budget.timeoutMs,
|
|
85
|
+
gracePeriodMs,
|
|
86
|
+
elapsedMs: elapsed,
|
|
87
|
+
remainingMs: remaining,
|
|
88
|
+
budget,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createPlannerGraceProgress(
|
|
93
|
+
budget: Readonly<PlannerBudget>,
|
|
94
|
+
gracePeriodMs: number,
|
|
95
|
+
): PlannerProgressEvent {
|
|
96
|
+
const grace = Math.max(0, gracePeriodMs);
|
|
97
|
+
return {
|
|
98
|
+
state: "grace",
|
|
99
|
+
message: `Planning budget reached after ${formatFriendlyDuration(budget.timeoutMs)}; entering a ${formatFriendlyDurationModifier(grace)} graceful-shutdown period to finish a valid plan.`,
|
|
100
|
+
budgetMs: budget.timeoutMs,
|
|
101
|
+
gracePeriodMs: grace,
|
|
102
|
+
elapsedMs: budget.timeoutMs,
|
|
103
|
+
remainingMs: 0,
|
|
104
|
+
graceRemainingMs: grace,
|
|
105
|
+
budget,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Three bounded checkpoints provide useful timing without per-second noise. */
|
|
110
|
+
export function plannerProgressCheckpoints(budgetMs: number): number[] {
|
|
111
|
+
const budget = Math.max(0, Math.floor(budgetMs));
|
|
112
|
+
if (budget <= 1) {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
return [...new Set([0.25, 0.5, 0.75].map((fraction) => Math.max(1, Math.floor(budget * fraction))))].filter(
|
|
116
|
+
(checkpoint) => checkpoint < budget,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function formatFriendlyDurationModifier(durationMs: number): string {
|
|
121
|
+
return formatFriendlyDuration(durationMs).replace(
|
|
122
|
+
/(\d[\d,]*) (seconds?|minutes?|hours?|days?)/g,
|
|
123
|
+
(_match, value: string, unit: string) => `${value}-${unit.replace(/s$/, "")}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function adaptiveExtensionExplanation(budget: Readonly<PlannerBudget>): string {
|
|
128
|
+
if (!budget.extensionApplied || budget.extensionMs <= 0 || !budget.trigger) {
|
|
129
|
+
return "";
|
|
130
|
+
}
|
|
131
|
+
return ` Adaptive extension: ${formatFriendlyDuration(budget.extensionMs)} because the request includes ${complexitySignalText(budget.trigger)}.`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function complexitySignalText(signal: Readonly<PlannerComplexitySignal>): string {
|
|
135
|
+
switch (signal.kind) {
|
|
136
|
+
case "separately_planned_tasks":
|
|
137
|
+
return `${signal.itemCount} separately planned ${signal.itemCount === 1 ? "task" : "tasks"}`;
|
|
138
|
+
case "explicit_item_count":
|
|
139
|
+
return `an explicit count of ${signal.itemCount} ${signal.itemCount === 1 ? "deliverable" : "deliverables"}`;
|
|
140
|
+
case "enumerated_deliverables":
|
|
141
|
+
return `${signal.itemCount} enumerated ${signal.itemCount === 1 ? "deliverable" : "deliverables"}`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function plural(value: number, unit: string): string {
|
|
146
|
+
return `${value.toLocaleString("en-US")} ${unit}${value === 1 ? "" : "s"}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function joinDurationParts(first: string, second: string | undefined): string {
|
|
150
|
+
return second ? `${first} ${second}` : first;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function clamp(value: number, minimum: number, maximum: number): number {
|
|
154
|
+
const finite = Number.isFinite(value) ? value : minimum;
|
|
155
|
+
return Math.min(maximum, Math.max(minimum, Math.round(finite)));
|
|
156
|
+
}
|
package/src/render.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { Text, type Component } from "@earendil-works/pi-tui";
|
|
|
4
4
|
import type { GoalLoopState, GoalLoopStatus } from "./goal_loop.ts";
|
|
5
5
|
import type { TaskProgressModel } from "./task_progress.ts";
|
|
6
6
|
import type { CoordinatorCommitSummary, CoordinatorRemainingTask, CoordinatorStatus } from "./types.ts";
|
|
7
|
+
import type { WorkerCapabilityWarning } from "./worker_capabilities.ts";
|
|
7
8
|
|
|
8
9
|
export interface CoordinatorResultForRendering {
|
|
9
10
|
status: CoordinatorStatus;
|
|
@@ -19,6 +20,7 @@ export interface CoordinatorResultForRendering {
|
|
|
19
20
|
remainingTasks?: CoordinatorRemainingTask[];
|
|
20
21
|
taskProgress?: TaskProgressModel;
|
|
21
22
|
workerCostTotal?: number;
|
|
23
|
+
capabilityWarnings?: readonly WorkerCapabilityWarning[];
|
|
22
24
|
goal?: string;
|
|
23
25
|
error?: string;
|
|
24
26
|
}
|
|
@@ -56,6 +58,10 @@ export function formatCoordinatorResultMessage(result: CoordinatorResultForRende
|
|
|
56
58
|
lines.push(`Worker spend: ${formatCost(result.workerCostTotal)}`);
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
if (result.capabilityWarnings?.length) {
|
|
62
|
+
lines.push("Worker capability warnings:", ...result.capabilityWarnings.map((warning) => `- ${warning.message}`));
|
|
63
|
+
}
|
|
64
|
+
|
|
59
65
|
const commitLines = commits
|
|
60
66
|
.filter((commit) => commit.hash || commit.error)
|
|
61
67
|
.map((commit) => {
|
|
@@ -385,6 +391,9 @@ function renderLongTaskSummary(details: CoordinatorToolRenderDetails, expanded:
|
|
|
385
391
|
}
|
|
386
392
|
|
|
387
393
|
const lines = [summary.join(" — "), theme.fg("muted", details.summary)];
|
|
394
|
+
for (const warning of details.capabilityWarnings ?? []) {
|
|
395
|
+
lines.push(theme.fg("warning", `Warning: ${warning.message}`));
|
|
396
|
+
}
|
|
388
397
|
lines.push(theme.fg("dim", `Result: ${details.resultPath ?? details.taskResultPath ?? "unknown"}`));
|
|
389
398
|
lines.push(theme.fg("dim", `TODO: ${details.todoPath}`));
|
|
390
399
|
|
|
@@ -450,6 +459,8 @@ function progressSubtaskDetails(value: unknown): ProgressSubtaskRenderDetails[]
|
|
|
450
459
|
|
|
451
460
|
function progressPhaseLabel(phase: string): string {
|
|
452
461
|
switch (phase) {
|
|
462
|
+
case "capability_warning":
|
|
463
|
+
return "Warning";
|
|
453
464
|
case "planning":
|
|
454
465
|
case "planned":
|
|
455
466
|
return "Thought";
|
|
@@ -605,10 +616,35 @@ function longTaskDetails(details: Record<string, unknown> | undefined): Coordina
|
|
|
605
616
|
remainingTasks: remainingTaskSummaries(details.remainingTasks),
|
|
606
617
|
taskProgress: taskProgressModel(details.taskProgress),
|
|
607
618
|
workerCostTotal: nonNegativeNumberValue(details.workerCostTotal),
|
|
619
|
+
capabilityWarnings: capabilityWarningDetails(details.capabilityWarnings),
|
|
608
620
|
error: stringValue(details.error),
|
|
609
621
|
};
|
|
610
622
|
}
|
|
611
623
|
|
|
624
|
+
function capabilityWarningDetails(value: unknown): WorkerCapabilityWarning[] {
|
|
625
|
+
if (!Array.isArray(value)) {
|
|
626
|
+
return [];
|
|
627
|
+
}
|
|
628
|
+
return value.flatMap((item) => {
|
|
629
|
+
const record = recordOrUndefined(item);
|
|
630
|
+
const code = stringValue(record?.code);
|
|
631
|
+
const message = stringValue(record?.message);
|
|
632
|
+
const planningConstraint = stringValue(record?.planningConstraint);
|
|
633
|
+
if (code !== "unavailable_browser_capability" || !message || !planningConstraint) {
|
|
634
|
+
return [];
|
|
635
|
+
}
|
|
636
|
+
return [
|
|
637
|
+
{
|
|
638
|
+
code,
|
|
639
|
+
message,
|
|
640
|
+
planningConstraint,
|
|
641
|
+
requestedCapabilities: stringArray(record?.requestedCapabilities),
|
|
642
|
+
availableTools: stringArray(record?.availableTools),
|
|
643
|
+
},
|
|
644
|
+
];
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
612
648
|
function commitSummaries(value: unknown): CoordinatorCommitSummary[] {
|
|
613
649
|
if (!Array.isArray(value)) {
|
|
614
650
|
return [];
|