pi-long-task 0.3.17 → 0.5.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 +25 -0
- package/README.md +74 -8
- package/package.json +2 -2
- package/src/coordinator.ts +1102 -50
- package/src/index.ts +60 -18
- package/src/plan_revision.ts +471 -0
- package/src/plan_revision_generation.ts +376 -0
- package/src/plan_store.ts +281 -0
- package/src/render.ts +2 -0
- package/src/steering.ts +234 -0
- package/src/todo_parser.ts +46 -7
- package/src/worker_config.ts +63 -14
- package/src/worker_reuse_policy.ts +389 -0
- package/src/worker_session.ts +261 -33
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
/** Reuse starts conservatively rotating before two thirds of the context is occupied. */
|
|
4
|
+
export const DEFAULT_WORKER_SESSION_REUSE_CONTEXT_THRESHOLD_PERCENT = 62.5;
|
|
5
|
+
export const DEFAULT_WORKER_SESSION_REUSE_ENABLED = true;
|
|
6
|
+
|
|
7
|
+
export interface WorkerSessionReuseConfig {
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
contextThresholdPercent: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface WorkerSessionCompatibilityInput {
|
|
13
|
+
coordinatorRunId: string;
|
|
14
|
+
repositoryRoot: string;
|
|
15
|
+
worktreeRoot?: string;
|
|
16
|
+
provider?: string;
|
|
17
|
+
modelName?: string;
|
|
18
|
+
model?: unknown;
|
|
19
|
+
tools?: readonly string[];
|
|
20
|
+
thinkingLevel?: string;
|
|
21
|
+
/** Any additional worker option whose value changes session behavior. */
|
|
22
|
+
workerOptions?: Readonly<Record<string, unknown>>;
|
|
23
|
+
agentDir?: string;
|
|
24
|
+
modelRuntime?: unknown;
|
|
25
|
+
authStorage?: unknown;
|
|
26
|
+
modelRegistry?: unknown;
|
|
27
|
+
settingsManager?: unknown;
|
|
28
|
+
resourceLoader?: unknown;
|
|
29
|
+
sessionFactory?: unknown;
|
|
30
|
+
/** Additional options that can alter the constructed AgentSession. */
|
|
31
|
+
sessionConfiguration?: Readonly<Record<string, unknown>>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A run-scoped description of every input that can make two worker sessions
|
|
36
|
+
* incompatible. Individual fields are retained so policy diagnostics can name
|
|
37
|
+
* the mismatch instead of reporting only an opaque hash.
|
|
38
|
+
*/
|
|
39
|
+
export interface WorkerSessionCompatibilityFingerprint {
|
|
40
|
+
coordinatorRunId: string;
|
|
41
|
+
repositoryRoot: string;
|
|
42
|
+
worktreeRoot: string;
|
|
43
|
+
provider: string;
|
|
44
|
+
model: string;
|
|
45
|
+
workerOptions: string;
|
|
46
|
+
sessionConfiguration: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type WorkerSessionHealth =
|
|
50
|
+
| "healthy"
|
|
51
|
+
| "unknown"
|
|
52
|
+
| "timed_out"
|
|
53
|
+
| "aborted"
|
|
54
|
+
| "cancelled"
|
|
55
|
+
| "unrecoverable_error"
|
|
56
|
+
| "invalid_state";
|
|
57
|
+
|
|
58
|
+
export type WorkerSessionReuseState = "reusable" | "rotation_required" | "isolated";
|
|
59
|
+
|
|
60
|
+
export type WorkerSessionRetryMode = "no_retry" | "safe_partial_continuation" | "independent_retry";
|
|
61
|
+
|
|
62
|
+
export type WorkerSessionRetryReasonCode =
|
|
63
|
+
| "task_completed"
|
|
64
|
+
| "explicit_partial_work"
|
|
65
|
+
| "retry_after_timeout"
|
|
66
|
+
| "retry_after_abort"
|
|
67
|
+
| "retry_after_cancellation"
|
|
68
|
+
| "retry_after_error"
|
|
69
|
+
| "retry_after_invalid_result"
|
|
70
|
+
| "retry_after_non_partial_status";
|
|
71
|
+
|
|
72
|
+
export interface WorkerSessionRetryInput {
|
|
73
|
+
done: boolean;
|
|
74
|
+
reportedStatus: string;
|
|
75
|
+
completeTaskResult: boolean;
|
|
76
|
+
timedOut: boolean;
|
|
77
|
+
aborted: boolean;
|
|
78
|
+
cancelled?: boolean;
|
|
79
|
+
error?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface WorkerSessionRetryDecision {
|
|
83
|
+
mode: WorkerSessionRetryMode;
|
|
84
|
+
reasonCode: WorkerSessionRetryReasonCode;
|
|
85
|
+
mayContinueInSession: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type WorkerSessionReuseReasonCode =
|
|
89
|
+
| "reuse_eligible"
|
|
90
|
+
| "reuse_disabled"
|
|
91
|
+
| "session_disposed"
|
|
92
|
+
| "session_active"
|
|
93
|
+
| "health_unknown"
|
|
94
|
+
| "health_timed_out"
|
|
95
|
+
| "health_aborted"
|
|
96
|
+
| "health_cancelled"
|
|
97
|
+
| "health_unrecoverable_error"
|
|
98
|
+
| "health_invalid_state"
|
|
99
|
+
| "coordinator_run_mismatch"
|
|
100
|
+
| "repository_mismatch"
|
|
101
|
+
| "worktree_mismatch"
|
|
102
|
+
| "provider_mismatch"
|
|
103
|
+
| "model_mismatch"
|
|
104
|
+
| "worker_options_mismatch"
|
|
105
|
+
| "session_configuration_mismatch"
|
|
106
|
+
| "context_usage_unavailable"
|
|
107
|
+
| "context_usage_invalid"
|
|
108
|
+
| "context_threshold_reached";
|
|
109
|
+
|
|
110
|
+
export interface WorkerSessionReuseCandidate {
|
|
111
|
+
health: WorkerSessionHealth;
|
|
112
|
+
compatibility: WorkerSessionCompatibilityFingerprint;
|
|
113
|
+
/** A normalized percentage in the inclusive range 0..100. */
|
|
114
|
+
contextUsagePercent?: number;
|
|
115
|
+
assignmentState: "idle" | "active";
|
|
116
|
+
disposed: boolean;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface WorkerSessionReusePolicyInput {
|
|
120
|
+
config: WorkerSessionReuseConfig;
|
|
121
|
+
candidate: WorkerSessionReuseCandidate;
|
|
122
|
+
requestedCompatibility: WorkerSessionCompatibilityFingerprint;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface WorkerSessionReuseDecision {
|
|
126
|
+
state: WorkerSessionReuseState;
|
|
127
|
+
reasonCode: WorkerSessionReuseReasonCode;
|
|
128
|
+
reusable: boolean;
|
|
129
|
+
healthy: boolean;
|
|
130
|
+
contextUsagePercent?: number;
|
|
131
|
+
contextThresholdPercent: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const objectIds = new WeakMap<object, number>();
|
|
135
|
+
const symbolIds = new Map<symbol, number>();
|
|
136
|
+
let nextObjectId = 1;
|
|
137
|
+
|
|
138
|
+
export function normalizeWorkerSessionReuseThreshold(value: unknown): number {
|
|
139
|
+
return isValidThresholdPercentage(value) ? value : DEFAULT_WORKER_SESSION_REUSE_CONTEXT_THRESHOLD_PERCENT;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function resolveWorkerSessionReuseConfig(options: {
|
|
143
|
+
enabled?: boolean;
|
|
144
|
+
contextThresholdPercent?: number;
|
|
145
|
+
}): WorkerSessionReuseConfig {
|
|
146
|
+
return {
|
|
147
|
+
enabled: options.enabled ?? DEFAULT_WORKER_SESSION_REUSE_ENABLED,
|
|
148
|
+
contextThresholdPercent: normalizeWorkerSessionReuseThreshold(options.contextThresholdPercent),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function createWorkerSessionCompatibilityFingerprint(
|
|
153
|
+
input: WorkerSessionCompatibilityInput,
|
|
154
|
+
): WorkerSessionCompatibilityFingerprint {
|
|
155
|
+
const parsedModel = splitProviderModel(input.modelName);
|
|
156
|
+
const provider = normalizeOptional(input.provider) ?? parsedModel.provider ?? "<default>";
|
|
157
|
+
const model = parsedModel.model
|
|
158
|
+
? `name:${parsedModel.model}`
|
|
159
|
+
: input.model !== undefined
|
|
160
|
+
? `value:${fingerprintValue(input.model)}`
|
|
161
|
+
: "<default>";
|
|
162
|
+
const repositoryRoot = path.resolve(input.repositoryRoot);
|
|
163
|
+
const worktreeRoot = path.resolve(input.worktreeRoot ?? repositoryRoot);
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
coordinatorRunId: input.coordinatorRunId,
|
|
167
|
+
repositoryRoot,
|
|
168
|
+
worktreeRoot,
|
|
169
|
+
provider,
|
|
170
|
+
model,
|
|
171
|
+
workerOptions: canonicalRecord({
|
|
172
|
+
tools: input.tools ? [...input.tools] : undefined,
|
|
173
|
+
thinkingLevel: input.thinkingLevel,
|
|
174
|
+
workerOptions: input.workerOptions,
|
|
175
|
+
}),
|
|
176
|
+
sessionConfiguration: canonicalRecord({
|
|
177
|
+
agentDir: input.agentDir ? path.resolve(input.agentDir) : undefined,
|
|
178
|
+
modelRuntime: fingerprintOpaqueOption(input.modelRuntime),
|
|
179
|
+
authStorage: fingerprintOpaqueOption(input.authStorage),
|
|
180
|
+
modelRegistry: fingerprintOpaqueOption(input.modelRegistry),
|
|
181
|
+
settingsManager: fingerprintOpaqueOption(input.settingsManager),
|
|
182
|
+
resourceLoader: fingerprintOpaqueOption(input.resourceLoader),
|
|
183
|
+
sessionFactory: fingerprintOpaqueOption(input.sessionFactory),
|
|
184
|
+
sessionConfiguration: input.sessionConfiguration,
|
|
185
|
+
}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Classify retry ownership independently from session health. Only an explicit,
|
|
191
|
+
* complete `partial` result is eligible to continue in the same session; every
|
|
192
|
+
* failure mode remains an independent attempt and therefore starts fresh.
|
|
193
|
+
*/
|
|
194
|
+
export function classifyWorkerSessionRetry(input: WorkerSessionRetryInput): WorkerSessionRetryDecision {
|
|
195
|
+
if (input.timedOut) {
|
|
196
|
+
return { mode: "independent_retry", reasonCode: "retry_after_timeout", mayContinueInSession: false };
|
|
197
|
+
}
|
|
198
|
+
if (input.cancelled) {
|
|
199
|
+
return { mode: "independent_retry", reasonCode: "retry_after_cancellation", mayContinueInSession: false };
|
|
200
|
+
}
|
|
201
|
+
if (input.aborted) {
|
|
202
|
+
return { mode: "independent_retry", reasonCode: "retry_after_abort", mayContinueInSession: false };
|
|
203
|
+
}
|
|
204
|
+
if (input.error) {
|
|
205
|
+
return { mode: "independent_retry", reasonCode: "retry_after_error", mayContinueInSession: false };
|
|
206
|
+
}
|
|
207
|
+
if (input.done) {
|
|
208
|
+
return { mode: "no_retry", reasonCode: "task_completed", mayContinueInSession: false };
|
|
209
|
+
}
|
|
210
|
+
if (!input.completeTaskResult) {
|
|
211
|
+
return { mode: "independent_retry", reasonCode: "retry_after_invalid_result", mayContinueInSession: false };
|
|
212
|
+
}
|
|
213
|
+
if (input.reportedStatus.trim().toLowerCase() === "partial") {
|
|
214
|
+
return { mode: "safe_partial_continuation", reasonCode: "explicit_partial_work", mayContinueInSession: true };
|
|
215
|
+
}
|
|
216
|
+
return { mode: "independent_retry", reasonCode: "retry_after_non_partial_status", mayContinueInSession: false };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Decide whether an idle completed session can accept the next assignment. */
|
|
220
|
+
export function decideWorkerSessionReuse(input: WorkerSessionReusePolicyInput): WorkerSessionReuseDecision {
|
|
221
|
+
const threshold = normalizeWorkerSessionReuseThreshold(input.config.contextThresholdPercent);
|
|
222
|
+
const base = {
|
|
223
|
+
contextThresholdPercent: threshold,
|
|
224
|
+
...(input.candidate.contextUsagePercent !== undefined
|
|
225
|
+
? { contextUsagePercent: input.candidate.contextUsagePercent }
|
|
226
|
+
: {}),
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
if (!input.config.enabled) {
|
|
230
|
+
return {
|
|
231
|
+
...base,
|
|
232
|
+
state: "isolated",
|
|
233
|
+
reasonCode: "reuse_disabled",
|
|
234
|
+
reusable: false,
|
|
235
|
+
healthy: input.candidate.health === "healthy" && !input.candidate.disposed,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (input.candidate.disposed) {
|
|
239
|
+
return { ...base, state: "rotation_required", reasonCode: "session_disposed", reusable: false, healthy: false };
|
|
240
|
+
}
|
|
241
|
+
if (input.candidate.assignmentState === "active") {
|
|
242
|
+
return { ...base, state: "rotation_required", reasonCode: "session_active", reusable: false, healthy: false };
|
|
243
|
+
}
|
|
244
|
+
if (input.candidate.health !== "healthy") {
|
|
245
|
+
return {
|
|
246
|
+
...base,
|
|
247
|
+
state: "rotation_required",
|
|
248
|
+
reasonCode: healthReason(input.candidate.health),
|
|
249
|
+
reusable: false,
|
|
250
|
+
healthy: false,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const incompatibility = compatibilityReason(input.candidate.compatibility, input.requestedCompatibility);
|
|
255
|
+
if (incompatibility) {
|
|
256
|
+
return { ...base, state: "rotation_required", reasonCode: incompatibility, reusable: false, healthy: true };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const usage = input.candidate.contextUsagePercent;
|
|
260
|
+
if (usage === undefined) {
|
|
261
|
+
return {
|
|
262
|
+
...base,
|
|
263
|
+
state: "rotation_required",
|
|
264
|
+
reasonCode: "context_usage_unavailable",
|
|
265
|
+
reusable: false,
|
|
266
|
+
healthy: true,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
if (!isValidContextUsagePercentage(usage)) {
|
|
270
|
+
return {
|
|
271
|
+
...base,
|
|
272
|
+
state: "rotation_required",
|
|
273
|
+
reasonCode: "context_usage_invalid",
|
|
274
|
+
reusable: false,
|
|
275
|
+
healthy: true,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (usage >= threshold) {
|
|
279
|
+
return {
|
|
280
|
+
...base,
|
|
281
|
+
state: "rotation_required",
|
|
282
|
+
reasonCode: "context_threshold_reached",
|
|
283
|
+
reusable: false,
|
|
284
|
+
healthy: true,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { ...base, state: "reusable", reasonCode: "reuse_eligible", reusable: true, healthy: true };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function compatibilityReason(
|
|
292
|
+
current: WorkerSessionCompatibilityFingerprint,
|
|
293
|
+
requested: WorkerSessionCompatibilityFingerprint,
|
|
294
|
+
): WorkerSessionReuseReasonCode | undefined {
|
|
295
|
+
if (current.coordinatorRunId !== requested.coordinatorRunId) return "coordinator_run_mismatch";
|
|
296
|
+
if (current.repositoryRoot !== requested.repositoryRoot) return "repository_mismatch";
|
|
297
|
+
if (current.worktreeRoot !== requested.worktreeRoot) return "worktree_mismatch";
|
|
298
|
+
if (current.provider !== requested.provider) return "provider_mismatch";
|
|
299
|
+
if (current.model !== requested.model) return "model_mismatch";
|
|
300
|
+
if (current.workerOptions !== requested.workerOptions) return "worker_options_mismatch";
|
|
301
|
+
if (current.sessionConfiguration !== requested.sessionConfiguration) return "session_configuration_mismatch";
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function healthReason(health: Exclude<WorkerSessionHealth, "healthy">): WorkerSessionReuseReasonCode {
|
|
306
|
+
return health === "unknown" ? "health_unknown" : `health_${health}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function splitProviderModel(modelName: string | undefined): { provider?: string; model?: string } {
|
|
310
|
+
const normalized = normalizeOptional(modelName);
|
|
311
|
+
if (!normalized) return {};
|
|
312
|
+
const separator = normalized.indexOf("/");
|
|
313
|
+
if (separator <= 0 || separator === normalized.length - 1) return { model: normalized };
|
|
314
|
+
return { provider: normalized.slice(0, separator), model: normalized.slice(separator + 1) };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function canonicalRecord(value: Readonly<Record<string, unknown>>, seen = new Set<object>()): string {
|
|
318
|
+
return Object.keys(value)
|
|
319
|
+
.sort()
|
|
320
|
+
.filter((key) => value[key] !== undefined)
|
|
321
|
+
.map((key) => `${JSON.stringify(key)}:${fingerprintValue(value[key], seen)}`)
|
|
322
|
+
.join("|");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function fingerprintValue(value: unknown, seen = new Set<object>()): string {
|
|
326
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
|
|
327
|
+
if (typeof value === "number") return Number.isFinite(value) ? String(value) : `number:${String(value)}`;
|
|
328
|
+
if (typeof value === "bigint" || typeof value === "symbol" || typeof value === "function") {
|
|
329
|
+
return opaqueValue(value);
|
|
330
|
+
}
|
|
331
|
+
if (value === undefined) return "undefined";
|
|
332
|
+
if (Array.isArray(value)) {
|
|
333
|
+
if (seen.has(value)) return opaqueValue(value);
|
|
334
|
+
seen.add(value);
|
|
335
|
+
const result = `[${value.map((item) => fingerprintValue(item, seen)).join(",")}]`;
|
|
336
|
+
seen.delete(value);
|
|
337
|
+
return result;
|
|
338
|
+
}
|
|
339
|
+
if (typeof value === "object") {
|
|
340
|
+
if (seen.has(value)) return opaqueValue(value);
|
|
341
|
+
const prototype = Object.getPrototypeOf(value);
|
|
342
|
+
if (prototype === Object.prototype || prototype === null) {
|
|
343
|
+
seen.add(value);
|
|
344
|
+
const result = `{${canonicalRecord(value as Record<string, unknown>, seen)}}`;
|
|
345
|
+
seen.delete(value);
|
|
346
|
+
return result;
|
|
347
|
+
}
|
|
348
|
+
return opaqueValue(value);
|
|
349
|
+
}
|
|
350
|
+
return String(value);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function fingerprintOpaqueOption(value: unknown): string | undefined {
|
|
354
|
+
return value === undefined ? undefined : opaqueValue(value);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function opaqueValue(value: unknown): string {
|
|
358
|
+
if ((typeof value !== "object" || value === null) && typeof value !== "function" && typeof value !== "symbol") {
|
|
359
|
+
return `${typeof value}:${String(value)}`;
|
|
360
|
+
}
|
|
361
|
+
if (typeof value === "symbol") {
|
|
362
|
+
let symbolId = symbolIds.get(value);
|
|
363
|
+
if (!symbolId) {
|
|
364
|
+
symbolId = nextObjectId++;
|
|
365
|
+
symbolIds.set(value, symbolId);
|
|
366
|
+
}
|
|
367
|
+
return `identity:${symbolId}`;
|
|
368
|
+
}
|
|
369
|
+
const object = value as object;
|
|
370
|
+
let objectId = objectIds.get(object);
|
|
371
|
+
if (!objectId) {
|
|
372
|
+
objectId = nextObjectId++;
|
|
373
|
+
objectIds.set(object, objectId);
|
|
374
|
+
}
|
|
375
|
+
return `identity:${objectId}`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function normalizeOptional(value: string | undefined): string | undefined {
|
|
379
|
+
const normalized = value?.trim();
|
|
380
|
+
return normalized || undefined;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function isValidThresholdPercentage(value: unknown): value is number {
|
|
384
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 && value <= 100;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function isValidContextUsagePercentage(value: unknown): value is number {
|
|
388
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 100;
|
|
389
|
+
}
|