pi-long-task 0.4.0 → 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 +16 -0
- package/README.md +54 -8
- package/package.json +2 -2
- package/src/coordinator.ts +686 -15
- package/src/index.ts +18 -5
- package/src/worker_config.ts +63 -14
- package/src/worker_reuse_policy.ts +389 -0
- package/src/worker_session.ts +261 -33
package/src/index.ts
CHANGED
|
@@ -276,10 +276,8 @@ function renderSidebarWidgetLines(update: CoordinatorProgressUpdate): string[] {
|
|
|
276
276
|
const progress = update.taskProgress;
|
|
277
277
|
const summary = progress?.summary;
|
|
278
278
|
const statusDetails = sidebarUpdateStateDetails(update);
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
`${statusDetails.icon} ${statusDetails.label} · ${update.activeStatus ?? update.message}`,
|
|
282
|
-
];
|
|
279
|
+
const statusText = normalizeActiveStatus(update.activeStatus ?? update.message);
|
|
280
|
+
const lines = ["Pi Long Task", `${statusDetails.icon} ${statusDetails.label} · ${statusText}`];
|
|
283
281
|
if (summary) {
|
|
284
282
|
lines.push(
|
|
285
283
|
`Tasks: ${summary.completedTasks}/${summary.totalTasks} · ${summary.completedPercent}%` +
|
|
@@ -396,7 +394,8 @@ function renderSidebarRows(update: CoordinatorProgressUpdate | undefined, theme:
|
|
|
396
394
|
rows.push(theme.fg("success", "No active task"));
|
|
397
395
|
}
|
|
398
396
|
|
|
399
|
-
const
|
|
397
|
+
const rawActiveStatus = update.activeStatus ?? normalizeMessageForSidebar(update.message, update);
|
|
398
|
+
const activeStatus = rawActiveStatus ? normalizeActiveStatus(rawActiveStatus) : undefined;
|
|
400
399
|
if (currentTask && activeStatus) {
|
|
401
400
|
rows.push("", sidebarHeading("Active status", theme));
|
|
402
401
|
rows.push(...wrapPlainText(activeStatus, width, 6).map((line) => theme.fg("accent", line)));
|
|
@@ -573,6 +572,8 @@ function sidebarUpdateStateDetails(update: CoordinatorProgressUpdate): {
|
|
|
573
572
|
return { icon: "✓", label: "Plan ready", color: "success" };
|
|
574
573
|
case "task_start":
|
|
575
574
|
return { icon: "▢", label: "Running task", color: "accent" };
|
|
575
|
+
case "worker_session":
|
|
576
|
+
return { icon: "↻", label: "Worker session", color: "accent" };
|
|
576
577
|
case "worker_tool":
|
|
577
578
|
return { icon: "+", label: "Worker tool", color: "warning" };
|
|
578
579
|
case "task_done":
|
|
@@ -681,6 +682,18 @@ function normalizeMessageForSidebar(updateMessage: string, update: CoordinatorPr
|
|
|
681
682
|
return title && message.includes(title) && message.length <= title.length + 16 ? undefined : message;
|
|
682
683
|
}
|
|
683
684
|
|
|
685
|
+
function normalizeActiveStatus(status: string): string {
|
|
686
|
+
const normalized = status.trim();
|
|
687
|
+
const firstOutcome = /^(Finished|Failed):\s*/i.exec(normalized)?.[1];
|
|
688
|
+
if (!firstOutcome) {
|
|
689
|
+
return normalized;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const activity = normalized.replace(/^(?:(?:Finished|Failed):\s*)+/i, "");
|
|
693
|
+
const outcome = firstOutcome.toLowerCase() === "failed" ? "Failed" : "Finished";
|
|
694
|
+
return activity ? `${outcome}: ${activity}` : `${outcome}:`;
|
|
695
|
+
}
|
|
696
|
+
|
|
684
697
|
function wrapPlainText(text: string, width: number, limit?: number): string[] {
|
|
685
698
|
const safeWidth = Math.max(8, width);
|
|
686
699
|
const words = text.trim().split(/\s+/).filter(Boolean);
|
package/src/worker_config.ts
CHANGED
|
@@ -3,8 +3,12 @@ export interface ParsedWorkerRuntimeConfig {
|
|
|
3
3
|
maxAttemptsPerTask?: number;
|
|
4
4
|
taskTimeoutMs?: number;
|
|
5
5
|
maxBashTimeoutMs?: number;
|
|
6
|
+
workerSessionReuseEnabled?: boolean;
|
|
7
|
+
workerSessionReuseContextThresholdPercent?: number;
|
|
6
8
|
}
|
|
7
9
|
|
|
10
|
+
type MutableWorkerRuntimeConfig = ParsedWorkerRuntimeConfig & { provider?: string; model?: string };
|
|
11
|
+
|
|
8
12
|
const MODEL_TOKEN_RE = /[A-Za-z0-9][A-Za-z0-9._~:+/@-]*/;
|
|
9
13
|
const STOP_WORDS = new Set([
|
|
10
14
|
"and",
|
|
@@ -28,7 +32,7 @@ const STOP_WORDS = new Set([
|
|
|
28
32
|
]);
|
|
29
33
|
|
|
30
34
|
export function parseWorkerRuntimeConfig(text: string): ParsedWorkerRuntimeConfig {
|
|
31
|
-
const state:
|
|
35
|
+
const state: MutableWorkerRuntimeConfig = {};
|
|
32
36
|
|
|
33
37
|
parseLineDirectives(text, state);
|
|
34
38
|
parseNaturalLanguageDirectives(text, state);
|
|
@@ -39,13 +43,16 @@ export function parseWorkerRuntimeConfig(text: string): ParsedWorkerRuntimeConfi
|
|
|
39
43
|
...(state.maxAttemptsPerTask !== undefined ? { maxAttemptsPerTask: state.maxAttemptsPerTask } : {}),
|
|
40
44
|
...(state.taskTimeoutMs !== undefined ? { taskTimeoutMs: state.taskTimeoutMs } : {}),
|
|
41
45
|
...(state.maxBashTimeoutMs !== undefined ? { maxBashTimeoutMs: state.maxBashTimeoutMs } : {}),
|
|
46
|
+
...(state.workerSessionReuseEnabled !== undefined
|
|
47
|
+
? { workerSessionReuseEnabled: state.workerSessionReuseEnabled }
|
|
48
|
+
: {}),
|
|
49
|
+
...(state.workerSessionReuseContextThresholdPercent !== undefined
|
|
50
|
+
? { workerSessionReuseContextThresholdPercent: state.workerSessionReuseContextThresholdPercent }
|
|
51
|
+
: {}),
|
|
42
52
|
};
|
|
43
53
|
}
|
|
44
54
|
|
|
45
|
-
function parseLineDirectives(
|
|
46
|
-
text: string,
|
|
47
|
-
state: ParsedWorkerRuntimeConfig & { provider?: string; model?: string },
|
|
48
|
-
): void {
|
|
55
|
+
function parseLineDirectives(text: string, state: MutableWorkerRuntimeConfig): void {
|
|
49
56
|
for (const rawLine of text.split(/\r?\n/)) {
|
|
50
57
|
const line = rawLine.replace(/^\s{0,3}>+\s?/, "").trim();
|
|
51
58
|
const match = line.match(
|
|
@@ -64,10 +71,7 @@ function parseLineDirectives(
|
|
|
64
71
|
}
|
|
65
72
|
}
|
|
66
73
|
|
|
67
|
-
function parseNaturalLanguageDirectives(
|
|
68
|
-
text: string,
|
|
69
|
-
state: ParsedWorkerRuntimeConfig & { provider?: string; model?: string },
|
|
70
|
-
): void {
|
|
74
|
+
function parseNaturalLanguageDirectives(text: string, state: MutableWorkerRuntimeConfig): void {
|
|
71
75
|
captureTokens(
|
|
72
76
|
text,
|
|
73
77
|
/\bworker\s+(?:model|provider\/model)\s*(?:is|=|:|to|as)?\s*[`'"]?([A-Za-z0-9][A-Za-z0-9._~:+/@-]*)/gi,
|
|
@@ -139,13 +143,38 @@ function parseNaturalLanguageDirectives(
|
|
|
139
143
|
state.maxBashTimeoutMs = value;
|
|
140
144
|
},
|
|
141
145
|
);
|
|
146
|
+
|
|
147
|
+
for (const match of text.matchAll(
|
|
148
|
+
/\b(?:worker\s+)?session\s+reuse(?:\s+is|\s*=|\s*:|\s+to)?\s+(enabled|disabled|on|off|true|false)\b/gi,
|
|
149
|
+
)) {
|
|
150
|
+
const enabled = booleanSetting(match[1] ?? "");
|
|
151
|
+
if (enabled !== undefined) state.workerSessionReuseEnabled = enabled;
|
|
152
|
+
}
|
|
153
|
+
for (const match of text.matchAll(
|
|
154
|
+
/\b(?:worker\s+)?(?:session\s+)?reuse\s+context(?:\s+usage)?\s+threshold\s*(?:is|=|:|to|of)?\s*(\d+(?:\.\d+)?)\s*%/gi,
|
|
155
|
+
)) {
|
|
156
|
+
const threshold = percentageFromText(match[1] ?? "");
|
|
157
|
+
if (threshold !== undefined) state.workerSessionReuseContextThresholdPercent = threshold;
|
|
158
|
+
}
|
|
142
159
|
}
|
|
143
160
|
|
|
144
|
-
function applyDirective(
|
|
145
|
-
key
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
161
|
+
function applyDirective(key: string, value: string, state: MutableWorkerRuntimeConfig): void {
|
|
162
|
+
if (/\breuse\b/.test(key) && /\b(?:threshold|context)\b/.test(key)) {
|
|
163
|
+
const threshold = percentageFromText(value);
|
|
164
|
+
if (threshold !== undefined) {
|
|
165
|
+
state.workerSessionReuseContextThresholdPercent = threshold;
|
|
166
|
+
}
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (/\breuse\b/.test(key)) {
|
|
171
|
+
const enabled = booleanSetting(value);
|
|
172
|
+
if (enabled !== undefined) {
|
|
173
|
+
state.workerSessionReuseEnabled = enabled;
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
149
178
|
if (/\bprovider\b/.test(key)) {
|
|
150
179
|
const token = modelToken(value);
|
|
151
180
|
if (token) {
|
|
@@ -250,6 +279,26 @@ function positiveIntegerFromText(value: string): number | undefined {
|
|
|
250
279
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
251
280
|
}
|
|
252
281
|
|
|
282
|
+
function percentageFromText(value: string): number | undefined {
|
|
283
|
+
const match = /\d+(?:\.\d+)?/.exec(value);
|
|
284
|
+
if (!match) {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
const parsed = Number.parseFloat(match[0]);
|
|
288
|
+
return Number.isFinite(parsed) && parsed > 0 && parsed <= 100 ? parsed : undefined;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function booleanSetting(value: string): boolean | undefined {
|
|
292
|
+
const normalized = trimDirectiveValue(value).toLowerCase().split(/\s+/)[0];
|
|
293
|
+
if (normalized === "enabled" || normalized === "on" || normalized === "true" || normalized === "yes") {
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
if (normalized === "disabled" || normalized === "off" || normalized === "false" || normalized === "no") {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
|
|
253
302
|
function durationMsFromText(value: string, options: { allowBareSeconds: boolean }): number | undefined {
|
|
254
303
|
const match = /(\d+(?:\.\d+)?)\s*(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h)?\b/i.exec(
|
|
255
304
|
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
|
+
}
|