pi-long-task 0.4.0 → 0.6.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 +34 -0
- package/README.md +139 -10
- package/package.json +2 -2
- package/src/coordinator.ts +1132 -45
- package/src/goal_discovery.ts +2 -0
- package/src/goal_loop.ts +76 -0
- package/src/goal_orchestrator.ts +87 -1
- package/src/goal_review.ts +206 -15
- package/src/goal_todo_execution.ts +3 -0
- package/src/goal_todo_generation.ts +96 -3
- package/src/index.ts +20 -5
- package/src/network_failure.ts +574 -0
- package/src/network_recovery.ts +395 -0
- package/src/network_recovery_config.ts +89 -0
- package/src/render.ts +2 -0
- package/src/session_guard.ts +8 -1
- package/src/todo_generator.ts +2 -2
- package/src/types.ts +32 -0
- package/src/worker_config.ts +137 -14
- package/src/worker_reuse_policy.ts +389 -0
- package/src/worker_session.ts +294 -34
package/src/worker_config.ts
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NetworkRecoveryConfigError,
|
|
3
|
+
resolveNetworkRecoveryConfig,
|
|
4
|
+
type NetworkRecoveryConfigInput,
|
|
5
|
+
} from "./network_recovery_config.ts";
|
|
6
|
+
|
|
1
7
|
export interface ParsedWorkerRuntimeConfig {
|
|
2
8
|
modelName?: string;
|
|
3
9
|
maxAttemptsPerTask?: number;
|
|
4
10
|
taskTimeoutMs?: number;
|
|
5
11
|
maxBashTimeoutMs?: number;
|
|
12
|
+
workerSessionReuseEnabled?: boolean;
|
|
13
|
+
workerSessionReuseContextThresholdPercent?: number;
|
|
14
|
+
networkRecovery?: NetworkRecoveryConfigInput;
|
|
6
15
|
}
|
|
7
16
|
|
|
17
|
+
type MutableWorkerRuntimeConfig = ParsedWorkerRuntimeConfig & { provider?: string; model?: string };
|
|
18
|
+
|
|
8
19
|
const MODEL_TOKEN_RE = /[A-Za-z0-9][A-Za-z0-9._~:+/@-]*/;
|
|
9
20
|
const STOP_WORDS = new Set([
|
|
10
21
|
"and",
|
|
@@ -28,10 +39,13 @@ const STOP_WORDS = new Set([
|
|
|
28
39
|
]);
|
|
29
40
|
|
|
30
41
|
export function parseWorkerRuntimeConfig(text: string): ParsedWorkerRuntimeConfig {
|
|
31
|
-
const state:
|
|
42
|
+
const state: MutableWorkerRuntimeConfig = {};
|
|
32
43
|
|
|
33
44
|
parseLineDirectives(text, state);
|
|
34
45
|
parseNaturalLanguageDirectives(text, state);
|
|
46
|
+
if (state.networkRecovery) {
|
|
47
|
+
resolveNetworkRecoveryConfig(state.networkRecovery);
|
|
48
|
+
}
|
|
35
49
|
|
|
36
50
|
const modelName = combineProviderAndModel(state.provider, state.model);
|
|
37
51
|
return {
|
|
@@ -39,13 +53,17 @@ export function parseWorkerRuntimeConfig(text: string): ParsedWorkerRuntimeConfi
|
|
|
39
53
|
...(state.maxAttemptsPerTask !== undefined ? { maxAttemptsPerTask: state.maxAttemptsPerTask } : {}),
|
|
40
54
|
...(state.taskTimeoutMs !== undefined ? { taskTimeoutMs: state.taskTimeoutMs } : {}),
|
|
41
55
|
...(state.maxBashTimeoutMs !== undefined ? { maxBashTimeoutMs: state.maxBashTimeoutMs } : {}),
|
|
56
|
+
...(state.workerSessionReuseEnabled !== undefined
|
|
57
|
+
? { workerSessionReuseEnabled: state.workerSessionReuseEnabled }
|
|
58
|
+
: {}),
|
|
59
|
+
...(state.workerSessionReuseContextThresholdPercent !== undefined
|
|
60
|
+
? { workerSessionReuseContextThresholdPercent: state.workerSessionReuseContextThresholdPercent }
|
|
61
|
+
: {}),
|
|
62
|
+
...(state.networkRecovery ? { networkRecovery: { ...state.networkRecovery } } : {}),
|
|
42
63
|
};
|
|
43
64
|
}
|
|
44
65
|
|
|
45
|
-
function parseLineDirectives(
|
|
46
|
-
text: string,
|
|
47
|
-
state: ParsedWorkerRuntimeConfig & { provider?: string; model?: string },
|
|
48
|
-
): void {
|
|
66
|
+
function parseLineDirectives(text: string, state: MutableWorkerRuntimeConfig): void {
|
|
49
67
|
for (const rawLine of text.split(/\r?\n/)) {
|
|
50
68
|
const line = rawLine.replace(/^\s{0,3}>+\s?/, "").trim();
|
|
51
69
|
const match = line.match(
|
|
@@ -64,10 +82,7 @@ function parseLineDirectives(
|
|
|
64
82
|
}
|
|
65
83
|
}
|
|
66
84
|
|
|
67
|
-
function parseNaturalLanguageDirectives(
|
|
68
|
-
text: string,
|
|
69
|
-
state: ParsedWorkerRuntimeConfig & { provider?: string; model?: string },
|
|
70
|
-
): void {
|
|
85
|
+
function parseNaturalLanguageDirectives(text: string, state: MutableWorkerRuntimeConfig): void {
|
|
71
86
|
captureTokens(
|
|
72
87
|
text,
|
|
73
88
|
/\bworker\s+(?:model|provider\/model)\s*(?:is|=|:|to|as)?\s*[`'"]?([A-Za-z0-9][A-Za-z0-9._~:+/@-]*)/gi,
|
|
@@ -139,13 +154,43 @@ function parseNaturalLanguageDirectives(
|
|
|
139
154
|
state.maxBashTimeoutMs = value;
|
|
140
155
|
},
|
|
141
156
|
);
|
|
157
|
+
|
|
158
|
+
for (const match of text.matchAll(
|
|
159
|
+
/\b(?:worker\s+)?session\s+reuse(?:\s+is|\s*=|\s*:|\s+to)?\s+(enabled|disabled|on|off|true|false)\b/gi,
|
|
160
|
+
)) {
|
|
161
|
+
const enabled = booleanSetting(match[1] ?? "");
|
|
162
|
+
if (enabled !== undefined) state.workerSessionReuseEnabled = enabled;
|
|
163
|
+
}
|
|
164
|
+
for (const match of text.matchAll(
|
|
165
|
+
/\b(?:worker\s+)?(?:session\s+)?reuse\s+context(?:\s+usage)?\s+threshold\s*(?:is|=|:|to|of)?\s*(\d+(?:\.\d+)?)\s*%/gi,
|
|
166
|
+
)) {
|
|
167
|
+
const threshold = percentageFromText(match[1] ?? "");
|
|
168
|
+
if (threshold !== undefined) state.workerSessionReuseContextThresholdPercent = threshold;
|
|
169
|
+
}
|
|
142
170
|
}
|
|
143
171
|
|
|
144
|
-
function applyDirective(
|
|
145
|
-
key
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
172
|
+
function applyDirective(key: string, value: string, state: MutableWorkerRuntimeConfig): void {
|
|
173
|
+
if (/\bnetwork\b/.test(key) && /\brecover(?:y|ies)?\b/.test(key)) {
|
|
174
|
+
applyNetworkRecoveryDirective(key, value, state);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (/\breuse\b/.test(key) && /\b(?:threshold|context)\b/.test(key)) {
|
|
179
|
+
const threshold = percentageFromText(value);
|
|
180
|
+
if (threshold !== undefined) {
|
|
181
|
+
state.workerSessionReuseContextThresholdPercent = threshold;
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (/\breuse\b/.test(key)) {
|
|
187
|
+
const enabled = booleanSetting(value);
|
|
188
|
+
if (enabled !== undefined) {
|
|
189
|
+
state.workerSessionReuseEnabled = enabled;
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
149
194
|
if (/\bprovider\b/.test(key)) {
|
|
150
195
|
const token = modelToken(value);
|
|
151
196
|
if (token) {
|
|
@@ -186,6 +231,64 @@ function applyDirective(
|
|
|
186
231
|
}
|
|
187
232
|
}
|
|
188
233
|
|
|
234
|
+
function applyNetworkRecoveryDirective(key: string, value: string, state: MutableWorkerRuntimeConfig): void {
|
|
235
|
+
const recovery = (state.networkRecovery ??= {});
|
|
236
|
+
|
|
237
|
+
if (/\bbase\b/.test(key) && /\bdelay\b/.test(key)) {
|
|
238
|
+
recovery.baseDelayMs = requiredNetworkRecoveryDuration("base delay", value);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (/\bmax(?:imum)?\b/.test(key) && /\bdelay\b/.test(key)) {
|
|
242
|
+
recovery.maxDelayMs = requiredNetworkRecoveryDuration("maximum delay", value);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (/\b(?:outage|duration|wait)\b/.test(key)) {
|
|
246
|
+
const normalized = trimDirectiveValue(value)
|
|
247
|
+
.toLowerCase()
|
|
248
|
+
.replace(/[.!]+$/g, "")
|
|
249
|
+
.trim();
|
|
250
|
+
if (/^(?:unlimited|indefinite|indefinitely|until cancelled|until canceled)$/.test(normalized)) {
|
|
251
|
+
recovery.maxOutageMs = null;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
recovery.maxOutageMs = requiredNetworkRecoveryDuration("maximum outage", value);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (/\b(?:enabled?|enablement)\b/.test(key) || /\bnetwork recovery\b/.test(key)) {
|
|
258
|
+
const enabled = booleanSetting(value);
|
|
259
|
+
if (enabled === undefined) {
|
|
260
|
+
throw new NetworkRecoveryConfigError(
|
|
261
|
+
"Network recovery must be configured as enabled/disabled, on/off, true/false, or yes/no.",
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
recovery.enabled = enabled;
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
throw new NetworkRecoveryConfigError(`Unknown network recovery configuration directive: ${key}.`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function requiredNetworkRecoveryDuration(label: string, value: string): number {
|
|
272
|
+
const trimmed = trimDirectiveValue(value)
|
|
273
|
+
.replace(/[.!]+$/g, "")
|
|
274
|
+
.trim();
|
|
275
|
+
const match = /^(\d+(?:\.\d+)?)\s*(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?$/i.exec(
|
|
276
|
+
trimmed,
|
|
277
|
+
);
|
|
278
|
+
if (!match) {
|
|
279
|
+
throw new NetworkRecoveryConfigError(
|
|
280
|
+
`Network recovery ${label} must be a positive finite duration (for example 1000ms, 30s, or 5m).`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
const milliseconds = durationMsFromText(trimmed, { allowBareSeconds: true });
|
|
284
|
+
if (milliseconds === undefined) {
|
|
285
|
+
throw new NetworkRecoveryConfigError(
|
|
286
|
+
`Network recovery ${label} must be a positive finite duration (for example 1000ms, 30s, or 5m).`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
return milliseconds;
|
|
290
|
+
}
|
|
291
|
+
|
|
189
292
|
function captureTokens(text: string, pattern: RegExp, apply: (token: string) => void): void {
|
|
190
293
|
for (const match of text.matchAll(pattern)) {
|
|
191
294
|
const token = modelToken(match[1] ?? "");
|
|
@@ -250,6 +353,26 @@ function positiveIntegerFromText(value: string): number | undefined {
|
|
|
250
353
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
251
354
|
}
|
|
252
355
|
|
|
356
|
+
function percentageFromText(value: string): number | undefined {
|
|
357
|
+
const match = /\d+(?:\.\d+)?/.exec(value);
|
|
358
|
+
if (!match) {
|
|
359
|
+
return undefined;
|
|
360
|
+
}
|
|
361
|
+
const parsed = Number.parseFloat(match[0]);
|
|
362
|
+
return Number.isFinite(parsed) && parsed > 0 && parsed <= 100 ? parsed : undefined;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function booleanSetting(value: string): boolean | undefined {
|
|
366
|
+
const normalized = trimDirectiveValue(value).toLowerCase().split(/\s+/)[0];
|
|
367
|
+
if (normalized === "enabled" || normalized === "on" || normalized === "true" || normalized === "yes") {
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
if (normalized === "disabled" || normalized === "off" || normalized === "false" || normalized === "no") {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
return undefined;
|
|
374
|
+
}
|
|
375
|
+
|
|
253
376
|
function durationMsFromText(value: string, options: { allowBareSeconds: boolean }): number | undefined {
|
|
254
377
|
const match = /(\d+(?:\.\d+)?)\s*(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?\b/i.exec(
|
|
255
378
|
value,
|
|
@@ -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
|
+
}
|