@modelprofile.com/flexharness 3.5.0 → 3.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 +18 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.flexharness.d.ts +64 -1
- package/dist_ts/classes.flexharness.js +2073 -139
- package/dist_ts/classes.stores.js +1 -1
- package/dist_ts/errors.d.ts +8 -0
- package/dist_ts/errors.js +17 -1
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/interfaces.d.ts +206 -5
- package/dist_ts/interfaces.js +14 -2
- package/dist_ts/plugins.d.ts +2 -2
- package/dist_ts/plugins.js +3 -3
- package/dist_ts/utils.json.d.ts +4 -2
- package/dist_ts/utils.json.js +253 -10
- package/dist_ts/utils.slashcommands.d.ts +7 -0
- package/dist_ts/utils.slashcommands.js +164 -0
- package/dist_ts_migration/v3_legacyflexharness.js +6 -2
- package/package.json +1 -1
- package/readme.hints.md +5 -0
- package/readme.md +149 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.flexharness.ts +2812 -152
- package/ts/classes.stores.ts +9 -8
- package/ts/errors.ts +25 -0
- package/ts/index.ts +4 -0
- package/ts/interfaces.ts +283 -5
- package/ts/plugins.ts +2 -0
- package/ts/utils.json.ts +284 -12
- package/ts/utils.slashcommands.ts +178 -0
- package/ts_migration/v3_legacyflexharness.ts +10 -5
|
@@ -10,11 +10,18 @@ import {
|
|
|
10
10
|
FlexHarnessQueueFullError,
|
|
11
11
|
FlexHarnessRunError,
|
|
12
12
|
FlexHarnessSessionBusyError,
|
|
13
|
+
FlexHarnessSlashCommandReentryError,
|
|
14
|
+
FlexHarnessSlashCommandUnavailableError,
|
|
13
15
|
FlexHarnessStoreConflictError,
|
|
14
16
|
FlexHarnessStoreCommitUncertainError,
|
|
15
17
|
FlexHarnessValidationError,
|
|
16
18
|
errorToInfo,
|
|
17
19
|
} from './errors.js';
|
|
20
|
+
import {
|
|
21
|
+
FLEX_REVERSION_DEFAULT_LIMITS,
|
|
22
|
+
FLEX_REVERSION_MAXIMUM_LIMITS,
|
|
23
|
+
FLEX_REVERSION_REFERENCE_MAX_BYTES,
|
|
24
|
+
} from './interfaces.js';
|
|
18
25
|
import type {
|
|
19
26
|
IFlexAgentContextInvocation,
|
|
20
27
|
IFlexAgentSessionPolicy,
|
|
@@ -41,20 +48,31 @@ import type {
|
|
|
41
48
|
IFlexPromptQueueLimits,
|
|
42
49
|
IFlexPromptOptions,
|
|
43
50
|
IFlexPromptResult,
|
|
44
|
-
|
|
51
|
+
IFlexProjectionSnapshotCurrent,
|
|
52
|
+
IFlexReversionLimits,
|
|
53
|
+
IFlexReversionSegment,
|
|
54
|
+
IFlexPendingReversionRelease,
|
|
55
|
+
IFlexRedoSessionResult,
|
|
45
56
|
IFlexResolvedModel,
|
|
46
57
|
IFlexResolvedScope,
|
|
58
|
+
IFlexResourceToolProviderDescriptor,
|
|
47
59
|
IFlexRunFinishedEvent,
|
|
48
60
|
IFlexScheduledPromptAdmission,
|
|
49
61
|
IFlexSchedulePromptOptions,
|
|
50
62
|
IFlexScopeSnapshot,
|
|
51
63
|
IFlexSession,
|
|
52
64
|
IFlexSessionTombstone,
|
|
65
|
+
IFlexSlashCommandDescriptor,
|
|
66
|
+
IFlexSlashCommandExecutionOptions,
|
|
67
|
+
IFlexSlashCommandHandlerRegistration,
|
|
68
|
+
IFlexSlashCommandTemplateRegistration,
|
|
53
69
|
IFlexSubagentDefinition,
|
|
54
70
|
IFlexTerminalProjection,
|
|
55
71
|
IFlexToolHandle,
|
|
56
72
|
IFlexToolMessagePart,
|
|
73
|
+
IFlexToolProviderContext,
|
|
57
74
|
IFlexUncertainToolExecution,
|
|
75
|
+
IFlexUndoSessionResult,
|
|
58
76
|
IFlexUpdateSessionOptions,
|
|
59
77
|
IFlexUsage,
|
|
60
78
|
IJsonObject,
|
|
@@ -71,7 +89,12 @@ import type {
|
|
|
71
89
|
TFlexPrompt,
|
|
72
90
|
TFlexPromptPart,
|
|
73
91
|
TFlexPromptQueueStatus,
|
|
92
|
+
TFlexSlashCommandExecutionResult,
|
|
93
|
+
TFlexSlashCommandRegistration,
|
|
94
|
+
TFlexPendingReversion,
|
|
95
|
+
TFlexProjectionSnapshot,
|
|
74
96
|
TFlexToolExecutionReconciliation,
|
|
97
|
+
TJsonValue,
|
|
75
98
|
} from './interfaces.js';
|
|
76
99
|
import { InMemoryFlexHarnessStores } from './classes.stores.js';
|
|
77
100
|
import {
|
|
@@ -86,6 +109,14 @@ import {
|
|
|
86
109
|
normalizeFlexPrompt,
|
|
87
110
|
type INormalizedFlexPrompt,
|
|
88
111
|
} from './utils.prompt.js';
|
|
112
|
+
import {
|
|
113
|
+
FLEX_SLASH_COMMAND_INITIALIZE_TEMPLATE,
|
|
114
|
+
FLEX_SLASH_COMMAND_MAX_INPUT_BYTES,
|
|
115
|
+
expandSlashCommandTemplate,
|
|
116
|
+
isValidSlashCommandName,
|
|
117
|
+
parseSlashCommand,
|
|
118
|
+
slashCommandTemplateHints,
|
|
119
|
+
} from './utils.slashcommands.js';
|
|
89
120
|
|
|
90
121
|
type TCanonicalOutcome = 'accepted' | 'rejected' | 'interrupted';
|
|
91
122
|
type TRunPhase =
|
|
@@ -102,6 +133,14 @@ interface IStoredSessionState {
|
|
|
102
133
|
session: IFlexSession;
|
|
103
134
|
messages: IFlexMessage[];
|
|
104
135
|
stagedTerminals: IFlexTerminalProjection[];
|
|
136
|
+
reversionSegments: IFlexReversionSegment[];
|
|
137
|
+
revertCursor: number;
|
|
138
|
+
excludedRunIds: string[];
|
|
139
|
+
pendingReversion?: TFlexPendingReversion;
|
|
140
|
+
pendingReversionReleases: IFlexPendingReversionRelease[];
|
|
141
|
+
projectionSchemaVersion: 1 | 2;
|
|
142
|
+
projectionBaseline?: TFlexProjectionSnapshot;
|
|
143
|
+
projectionReconciliationRequired: boolean;
|
|
105
144
|
projectionRevision: number;
|
|
106
145
|
projectionQueue: Promise<void>;
|
|
107
146
|
rememberedPermissionKeys: Set<string>;
|
|
@@ -149,6 +188,13 @@ interface IOrphanedTombstoneCleanup {
|
|
|
149
188
|
completion: Promise<void>;
|
|
150
189
|
}
|
|
151
190
|
|
|
191
|
+
interface IOrphanedTombstoneOwner<TScope> {
|
|
192
|
+
state: IStorageState;
|
|
193
|
+
rootSessionId: string;
|
|
194
|
+
scopeId: string;
|
|
195
|
+
scope: TScope;
|
|
196
|
+
}
|
|
197
|
+
|
|
152
198
|
interface IOrphanedExecutionContextOwner {
|
|
153
199
|
storageKey: string;
|
|
154
200
|
sessionId: string;
|
|
@@ -158,6 +204,7 @@ interface IStorageState {
|
|
|
158
204
|
storageKey: string;
|
|
159
205
|
compactorLifecycleController: AbortController;
|
|
160
206
|
scopeIdHint: string;
|
|
207
|
+
scopeContext: { scopeId: string; scope: unknown };
|
|
161
208
|
revision: number;
|
|
162
209
|
sessions: Map<string, IStoredSessionState>;
|
|
163
210
|
retainedSessionCleanups: Map<string, IRetainedSessionCleanup>;
|
|
@@ -259,6 +306,32 @@ interface IScopeAdmissionState {
|
|
|
259
306
|
retiring: boolean;
|
|
260
307
|
}
|
|
261
308
|
|
|
309
|
+
type TRegisteredSlashCommand<TScope> =
|
|
310
|
+
| Readonly<IFlexSlashCommandTemplateRegistration>
|
|
311
|
+
| Readonly<IFlexSlashCommandHandlerRegistration<TScope>>;
|
|
312
|
+
|
|
313
|
+
interface IActiveSlashCommandExecution {
|
|
314
|
+
kind: 'handler' | 'operation' | 'prompt-admission';
|
|
315
|
+
storageKey: string;
|
|
316
|
+
sessionId: string;
|
|
317
|
+
controller: AbortController;
|
|
318
|
+
completion: Promise<unknown>;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
interface IActiveSlashCommandListing {
|
|
322
|
+
storageKey: string;
|
|
323
|
+
sessionId: string;
|
|
324
|
+
controller: AbortController;
|
|
325
|
+
completion: Promise<unknown>;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
interface ISlashCommandInvocationOwner {
|
|
329
|
+
scopeId: string;
|
|
330
|
+
storageKey: string;
|
|
331
|
+
sessionId: string;
|
|
332
|
+
sessionKey: string;
|
|
333
|
+
}
|
|
334
|
+
|
|
262
335
|
interface IRunResultProjection {
|
|
263
336
|
text: string;
|
|
264
337
|
steps: number;
|
|
@@ -337,8 +410,17 @@ const defaultMaxSubagentDepth = 1;
|
|
|
337
410
|
const maximumMaxSubagentDepth = 8;
|
|
338
411
|
const defaultMaxSubagentCallsPerRun = 32;
|
|
339
412
|
const maximumMaxSubagentCallsPerRun = 128;
|
|
413
|
+
const maxResourceToolProviders = 128;
|
|
414
|
+
const maxResourceIdBytes = 512;
|
|
415
|
+
const maxResourceToolNameBytes = 512;
|
|
416
|
+
const maxSlashCommandRegistrations = 128;
|
|
417
|
+
const maxSlashCommandDescriptionBytes = 2048;
|
|
418
|
+
const maxSlashCommandTemplateBytes = FLEX_SLASH_COMMAND_MAX_INPUT_BYTES;
|
|
419
|
+
const resourceToolStemLength = 16;
|
|
340
420
|
const repairCancellationMessage = 'The process stopped before this run completed.';
|
|
341
421
|
const scopeRetirementMessage = 'The scope is being retired.';
|
|
422
|
+
const slashCommandSessionUnavailableReason = 'Session must be idle with no queued prompts.';
|
|
423
|
+
const reservedSlashCommandNames = new Set(['compact', 'undo', 'redo', 'init']);
|
|
342
424
|
const externalErrorFallback: IFlexErrorInfo = Object.freeze({
|
|
343
425
|
name: 'FlexHarnessExternalError',
|
|
344
426
|
message: 'The model operation failed.',
|
|
@@ -436,6 +518,40 @@ function validateIdentifier(value: string, name: string): void {
|
|
|
436
518
|
}
|
|
437
519
|
}
|
|
438
520
|
|
|
521
|
+
function sha256Hex(value: string): string {
|
|
522
|
+
return plugins.crypto.createHash('sha256').update(value, 'utf8').digest('hex');
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function createFlexResourceIdentityDigest(
|
|
526
|
+
resourceId: string,
|
|
527
|
+
attachmentRevision: number,
|
|
528
|
+
): string {
|
|
529
|
+
validateUtf8String(resourceId, 'resourceId', maxResourceIdBytes, true);
|
|
530
|
+
if (!Number.isSafeInteger(attachmentRevision) || attachmentRevision < 0) {
|
|
531
|
+
throw new FlexHarnessValidationError('attachmentRevision must be a non-negative safe integer.');
|
|
532
|
+
}
|
|
533
|
+
return sha256Hex(JSON.stringify([resourceId, attachmentRevision]));
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function createFlexResourceToolNamespace(
|
|
537
|
+
resourceId: string,
|
|
538
|
+
attachmentRevision: number,
|
|
539
|
+
): string {
|
|
540
|
+
const digest = createFlexResourceIdentityDigest(resourceId, attachmentRevision);
|
|
541
|
+
return `resource_${digest.slice(0, 16)}`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function createFlexResourceToolName(namespace: string, toolName: string): string {
|
|
545
|
+
if (!/^resource_[a-f0-9]{16}$/u.test(namespace)) {
|
|
546
|
+
throw new FlexHarnessValidationError('Resource tool namespace is invalid.');
|
|
547
|
+
}
|
|
548
|
+
validateUtf8String(toolName, 'resource tool name', maxResourceToolNameBytes, true);
|
|
549
|
+
const stem = toolName
|
|
550
|
+
.replace(/[^A-Za-z0-9_-]/gu, '_')
|
|
551
|
+
.slice(0, resourceToolStemLength) || 'tool';
|
|
552
|
+
return `${namespace}__${stem}__${sha256Hex(toolName).slice(0, 12)}`;
|
|
553
|
+
}
|
|
554
|
+
|
|
439
555
|
function validateUtf8String(
|
|
440
556
|
value: unknown,
|
|
441
557
|
name: string,
|
|
@@ -519,6 +635,89 @@ function normalizeSubagents(
|
|
|
519
635
|
return Object.freeze(normalized);
|
|
520
636
|
}
|
|
521
637
|
|
|
638
|
+
function normalizeSlashCommands<TScope>(
|
|
639
|
+
registrations: readonly TFlexSlashCommandRegistration<TScope>[] | undefined,
|
|
640
|
+
): ReadonlyMap<string, TRegisteredSlashCommand<TScope>> {
|
|
641
|
+
if (registrations === undefined) return new Map();
|
|
642
|
+
if (!Array.isArray(registrations) || registrations.length > maxSlashCommandRegistrations) {
|
|
643
|
+
throw new FlexHarnessValidationError(
|
|
644
|
+
`slashCommands must be an array with at most ${maxSlashCommandRegistrations} registrations.`,
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
const normalized = new Map<string, TRegisteredSlashCommand<TScope>>();
|
|
648
|
+
for (let index = 0; index < registrations.length; index++) {
|
|
649
|
+
const registration = registrations[index];
|
|
650
|
+
if (
|
|
651
|
+
!registration
|
|
652
|
+
|| typeof registration !== 'object'
|
|
653
|
+
|| Array.isArray(registration)
|
|
654
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(registration))
|
|
655
|
+
) {
|
|
656
|
+
throw new FlexHarnessValidationError(`slashCommands[${index}] must be a plain object.`);
|
|
657
|
+
}
|
|
658
|
+
const keys = Object.keys(registration);
|
|
659
|
+
const hasTemplate = Object.prototype.hasOwnProperty.call(registration, 'template');
|
|
660
|
+
const hasHandler = Object.prototype.hasOwnProperty.call(registration, 'handler');
|
|
661
|
+
const supported = hasTemplate
|
|
662
|
+
? ['name', 'description', 'template']
|
|
663
|
+
: ['name', 'description', 'handler'];
|
|
664
|
+
const unsupported = keys.find((key) => !supported.includes(key));
|
|
665
|
+
if (unsupported || hasTemplate === hasHandler) {
|
|
666
|
+
throw new FlexHarnessValidationError(
|
|
667
|
+
`slashCommands[${index}] must define exactly one of template or handler.`,
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
if (typeof registration.name !== 'string' || !isValidSlashCommandName(registration.name)) {
|
|
671
|
+
throw new FlexHarnessValidationError(`slashCommands[${index}].name is invalid.`);
|
|
672
|
+
}
|
|
673
|
+
if (reservedSlashCommandNames.has(registration.name)) {
|
|
674
|
+
throw new FlexHarnessValidationError(
|
|
675
|
+
`Slash command "${registration.name}" is reserved.`,
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
if (normalized.has(registration.name)) {
|
|
679
|
+
throw new FlexHarnessValidationError(`Duplicate slash command "${registration.name}".`);
|
|
680
|
+
}
|
|
681
|
+
if (registration.description !== undefined) {
|
|
682
|
+
validateUtf8String(
|
|
683
|
+
registration.description,
|
|
684
|
+
`slashCommands[${index}].description`,
|
|
685
|
+
maxSlashCommandDescriptionBytes,
|
|
686
|
+
true,
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
if (hasTemplate) {
|
|
690
|
+
const template = registration.template;
|
|
691
|
+
validateUtf8String(
|
|
692
|
+
template,
|
|
693
|
+
`slashCommands[${index}].template`,
|
|
694
|
+
maxSlashCommandTemplateBytes,
|
|
695
|
+
true,
|
|
696
|
+
);
|
|
697
|
+
normalized.set(registration.name, Object.freeze({
|
|
698
|
+
name: registration.name,
|
|
699
|
+
...(registration.description === undefined
|
|
700
|
+
? {}
|
|
701
|
+
: { description: registration.description }),
|
|
702
|
+
template,
|
|
703
|
+
}));
|
|
704
|
+
} else {
|
|
705
|
+
const handler = registration.handler;
|
|
706
|
+
if (typeof handler !== 'function') {
|
|
707
|
+
throw new FlexHarnessValidationError(`slashCommands[${index}].handler must be a function.`);
|
|
708
|
+
}
|
|
709
|
+
normalized.set(registration.name, Object.freeze({
|
|
710
|
+
name: registration.name,
|
|
711
|
+
...(registration.description === undefined
|
|
712
|
+
? {}
|
|
713
|
+
: { description: registration.description }),
|
|
714
|
+
handler,
|
|
715
|
+
}));
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return Object.freeze(normalized);
|
|
719
|
+
}
|
|
720
|
+
|
|
522
721
|
function resolveBoundedPositiveInteger(
|
|
523
722
|
value: number | undefined,
|
|
524
723
|
name: string,
|
|
@@ -793,6 +992,31 @@ function validatePromptOptions(options: IFlexPromptOptions, scheduled: boolean):
|
|
|
793
992
|
}
|
|
794
993
|
}
|
|
795
994
|
|
|
995
|
+
function validateSlashCommandExecutionOptions(
|
|
996
|
+
options: IFlexSlashCommandExecutionOptions,
|
|
997
|
+
): IFlexPromptOptions {
|
|
998
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
999
|
+
throw new FlexHarnessValidationError('Slash command execution options must be a plain object.');
|
|
1000
|
+
}
|
|
1001
|
+
const unsupported = Object.keys(options)
|
|
1002
|
+
.find((key) => !['modelHint', 'system', 'maxSteps', 'signal'].includes(key));
|
|
1003
|
+
if (unsupported) {
|
|
1004
|
+
throw new FlexHarnessValidationError(
|
|
1005
|
+
`Slash command execution options do not support "${unsupported}".`,
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) {
|
|
1009
|
+
throw new FlexHarnessValidationError('Slash command signal must be an AbortSignal.');
|
|
1010
|
+
}
|
|
1011
|
+
const promptOptions: IFlexPromptOptions = {
|
|
1012
|
+
...(options.modelHint === undefined ? {} : { modelHint: options.modelHint }),
|
|
1013
|
+
...(options.system === undefined ? {} : { system: options.system }),
|
|
1014
|
+
...(options.maxSteps === undefined ? {} : { maxSteps: options.maxSteps }),
|
|
1015
|
+
};
|
|
1016
|
+
validatePromptOptions(promptOptions, false);
|
|
1017
|
+
return promptOptions;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
796
1020
|
function resolveCallbackLimits(limits: IFlexCallbackLimits = {}): Required<IFlexCallbackLimits> {
|
|
797
1021
|
const resolved = {
|
|
798
1022
|
maxEvents: limits.maxEvents ?? DEFAULT_CALLBACK_LIMITS.maxEvents,
|
|
@@ -830,6 +1054,27 @@ function resolvePromptQueueLimits(
|
|
|
830
1054
|
return resolved;
|
|
831
1055
|
}
|
|
832
1056
|
|
|
1057
|
+
function resolveReversionLimits(
|
|
1058
|
+
limits: IFlexReversionLimits = {},
|
|
1059
|
+
): Required<IFlexReversionLimits> {
|
|
1060
|
+
const resolved = {
|
|
1061
|
+
maxCompletedTurns: limits.maxCompletedTurns ?? FLEX_REVERSION_DEFAULT_LIMITS.maxCompletedTurns,
|
|
1062
|
+
maxSegments: limits.maxSegments ?? FLEX_REVERSION_DEFAULT_LIMITS.maxSegments,
|
|
1063
|
+
maxExcludedRunIds: limits.maxExcludedRunIds ?? FLEX_REVERSION_DEFAULT_LIMITS.maxExcludedRunIds,
|
|
1064
|
+
maxPendingReversionReleases: limits.maxPendingReversionReleases
|
|
1065
|
+
?? FLEX_REVERSION_DEFAULT_LIMITS.maxPendingReversionReleases,
|
|
1066
|
+
};
|
|
1067
|
+
for (const [name, value] of Object.entries(resolved)) {
|
|
1068
|
+
const maximum = FLEX_REVERSION_MAXIMUM_LIMITS[name as keyof IFlexReversionLimits];
|
|
1069
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
|
|
1070
|
+
throw new FlexHarnessValidationError(
|
|
1071
|
+
`reversionLimits.${name} must be an integer from 1 through ${maximum}.`,
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
return resolved;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
833
1078
|
function normalizeAgentSessionPolicy<TScope>(
|
|
834
1079
|
policy: IFlexAgentSessionPolicy<TScope> = {},
|
|
835
1080
|
): IFlexAgentSessionPolicy<TScope> {
|
|
@@ -859,14 +1104,18 @@ export class FlexHarness<TScope = unknown> {
|
|
|
859
1104
|
private readonly scopeResolver: IFlexHarnessOptions<TScope>['scopeResolver'];
|
|
860
1105
|
private readonly modelResolver: IFlexHarnessOptions<TScope>['modelResolver'];
|
|
861
1106
|
private readonly toolProvider: IFlexHarnessOptions<TScope>['toolProvider'];
|
|
1107
|
+
private readonly resourceToolProviderResolver: IFlexHarnessOptions<TScope>['resourceToolProviderResolver'];
|
|
862
1108
|
private readonly executionContextProvider: IFlexHarnessOptions<TScope>['executionContextProvider'];
|
|
863
1109
|
private readonly stores: IFlexHarnessStores;
|
|
864
1110
|
private readonly agentSessionPolicy: IFlexAgentSessionPolicy<TScope>;
|
|
865
1111
|
private readonly toolOutputLimits: Required<NonNullable<IFlexHarnessOptions<TScope>['toolOutputLimits']>>;
|
|
866
1112
|
private readonly callbackLimits: Required<IFlexCallbackLimits>;
|
|
867
1113
|
private readonly promptQueueLimits: Required<IFlexPromptQueueLimits>;
|
|
1114
|
+
private readonly reversionLimits: Required<IFlexReversionLimits>;
|
|
1115
|
+
private readonly turnReversionProvider: IFlexHarnessOptions<TScope>['turnReversionProvider'];
|
|
868
1116
|
private readonly externalErrorProjector?: TFlexExternalErrorProjector;
|
|
869
1117
|
private readonly subagents: ReadonlyMap<string, Readonly<IFlexSubagentDefinition>>;
|
|
1118
|
+
private readonly slashCommands: ReadonlyMap<string, TRegisteredSlashCommand<TScope>>;
|
|
870
1119
|
private readonly maxSubagentDepth: number;
|
|
871
1120
|
private readonly maxSubagentCallsPerRun: number;
|
|
872
1121
|
private readonly stateLoads = new Map<string, Promise<IStorageState>>();
|
|
@@ -884,10 +1133,16 @@ export class FlexHarness<TScope = unknown> {
|
|
|
884
1133
|
>();
|
|
885
1134
|
private readonly orphanedProviderReleases = new Map<string, IOrphanedProviderRelease>();
|
|
886
1135
|
private readonly orphanedTombstoneCleanups = new Map<string, IOrphanedTombstoneCleanup>();
|
|
1136
|
+
private readonly orphanedTombstoneOwners = new Map<string, IOrphanedTombstoneOwner<TScope>>();
|
|
887
1137
|
private readonly pendingPromptAdmissionOwners = new Set<IPendingPromptAdmission>();
|
|
1138
|
+
private readonly activeSlashCommandExecutions = new Map<string, IActiveSlashCommandExecution>();
|
|
1139
|
+
private readonly activeSlashCommandListings = new Set<IActiveSlashCommandListing>();
|
|
888
1140
|
private readonly compactorInvocationContext = new plugins.AsyncLocalStorage<
|
|
889
1141
|
IFlexAgentContextInvocation<TScope>
|
|
890
1142
|
>();
|
|
1143
|
+
private readonly slashCommandInvocationContext = new plugins.AsyncLocalStorage<
|
|
1144
|
+
ISlashCommandInvocationOwner
|
|
1145
|
+
>();
|
|
891
1146
|
private readonly deferredCompactorContexts = new WeakMap<
|
|
892
1147
|
IFlexAgentContextInvocation<unknown>,
|
|
893
1148
|
IFlexAgentContextInvocation<unknown>
|
|
@@ -906,14 +1161,18 @@ export class FlexHarness<TScope = unknown> {
|
|
|
906
1161
|
this.scopeResolver = options.scopeResolver;
|
|
907
1162
|
this.modelResolver = options.modelResolver;
|
|
908
1163
|
this.toolProvider = options.toolProvider;
|
|
1164
|
+
this.resourceToolProviderResolver = options.resourceToolProviderResolver;
|
|
909
1165
|
this.executionContextProvider = options.executionContextProvider;
|
|
910
1166
|
this.stores = options.stores ?? new InMemoryFlexHarnessStores();
|
|
911
1167
|
this.agentSessionPolicy = normalizeAgentSessionPolicy(options.agentSessionPolicy);
|
|
912
1168
|
this.toolOutputLimits = resolveJsonLimits(options.toolOutputLimits);
|
|
913
1169
|
this.callbackLimits = resolveCallbackLimits(options.callbackLimits);
|
|
914
1170
|
this.promptQueueLimits = resolvePromptQueueLimits(options.promptQueueLimits);
|
|
1171
|
+
this.reversionLimits = resolveReversionLimits(options.reversionLimits);
|
|
1172
|
+
this.turnReversionProvider = options.turnReversionProvider;
|
|
915
1173
|
this.externalErrorProjector = options.externalErrorProjector;
|
|
916
1174
|
this.subagents = normalizeSubagents(options.subagents);
|
|
1175
|
+
this.slashCommands = normalizeSlashCommands(options.slashCommands);
|
|
917
1176
|
this.maxSubagentDepth = resolveBoundedPositiveInteger(
|
|
918
1177
|
options.maxSubagentDepth,
|
|
919
1178
|
'maxSubagentDepth',
|
|
@@ -931,6 +1190,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
931
1190
|
public async listSessions(scopeId: string): Promise<IFlexSession[]> {
|
|
932
1191
|
const { state } = await this.resolveState(scopeId);
|
|
933
1192
|
await state.scopeQueue;
|
|
1193
|
+
this.assertOpen();
|
|
934
1194
|
this.assertStateAcceptingWork(state);
|
|
935
1195
|
return publicSnapshot([...state.sessions.values()]
|
|
936
1196
|
.filter((stored) => !state.initializingSessions.has(stored.session.sessionId))
|
|
@@ -1124,7 +1384,14 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1124
1384
|
}
|
|
1125
1385
|
|
|
1126
1386
|
public async deleteSession(scopeId: string, sessionId: string): Promise<void> {
|
|
1387
|
+
const invocationOwner = this.slashCommandInvocationContext.getStore();
|
|
1388
|
+
if (invocationOwner?.scopeId === scopeId && invocationOwner.sessionId === sessionId) {
|
|
1389
|
+
throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
|
|
1390
|
+
}
|
|
1127
1391
|
const { scope, state } = await this.resolveState(scopeId);
|
|
1392
|
+
if (invocationOwner?.storageKey === state.storageKey) {
|
|
1393
|
+
throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(invocationOwner.sessionId));
|
|
1394
|
+
}
|
|
1128
1395
|
validateIdentifier(sessionId, 'sessionId');
|
|
1129
1396
|
await state.scopeQueue;
|
|
1130
1397
|
const deletionKey = state.tombstones.get(sessionId)?.rootSessionId ?? sessionId;
|
|
@@ -1199,6 +1466,21 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1199
1466
|
}
|
|
1200
1467
|
});
|
|
1201
1468
|
const reason = this.trustInternalError(new FlexHarnessAbortError('The session subtree was deleted.'));
|
|
1469
|
+
const slashCommandErrors: unknown[] = [];
|
|
1470
|
+
for (const deleted of deletedSessions) {
|
|
1471
|
+
const listingSettlement = this.abortSlashCommandListings(
|
|
1472
|
+
state.storageKey,
|
|
1473
|
+
reason,
|
|
1474
|
+
deleted.sessionId,
|
|
1475
|
+
);
|
|
1476
|
+
if (listingSettlement) await listingSettlement;
|
|
1477
|
+
await this.abortSlashCommandExecutions(
|
|
1478
|
+
state.storageKey,
|
|
1479
|
+
reason,
|
|
1480
|
+
slashCommandErrors,
|
|
1481
|
+
deleted.sessionId,
|
|
1482
|
+
);
|
|
1483
|
+
}
|
|
1202
1484
|
const childFirstDeletedSessions = [...deletedSessions].sort((left, right) =>
|
|
1203
1485
|
(right.depth ?? 0) - (left.depth ?? 0)
|
|
1204
1486
|
|| left.sessionId.localeCompare(right.sessionId));
|
|
@@ -1213,7 +1495,10 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1213
1495
|
}
|
|
1214
1496
|
}
|
|
1215
1497
|
try {
|
|
1216
|
-
const orphanErrors =
|
|
1498
|
+
const orphanErrors = [
|
|
1499
|
+
...slashCommandErrors,
|
|
1500
|
+
...await this.closeOrphanedResources(state.storageKey),
|
|
1501
|
+
];
|
|
1217
1502
|
if (orphanErrors.length > 0) throw combineErrors(orphanErrors);
|
|
1218
1503
|
for (const descendantRoot of descendantRoots) {
|
|
1219
1504
|
await this.finishTombstoneCleanup(
|
|
@@ -1240,7 +1525,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1240
1525
|
const stored = this.requireSession(state, sessionId);
|
|
1241
1526
|
await stored.projectionQueue;
|
|
1242
1527
|
this.assertStateAcceptingWork(state);
|
|
1243
|
-
return publicSnapshot(stored
|
|
1528
|
+
return publicSnapshot(this.visibleMessages(stored));
|
|
1244
1529
|
}
|
|
1245
1530
|
|
|
1246
1531
|
public async listMessagePage(
|
|
@@ -1270,20 +1555,21 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1270
1555
|
await stored.projectionQueue;
|
|
1271
1556
|
this.assertStateAcceptingWork(state);
|
|
1272
1557
|
const namespace = this.messageCursorNamespace(state.storageKey);
|
|
1273
|
-
|
|
1558
|
+
const visibleMessages = this.visibleMessages(stored);
|
|
1559
|
+
let end = visibleMessages.length;
|
|
1274
1560
|
if (options.before !== undefined) {
|
|
1275
1561
|
const cursor = this.parseMessageCursor(options.before);
|
|
1276
1562
|
if (cursor.namespace !== namespace || cursor.sessionId !== sessionId) {
|
|
1277
1563
|
throw new FlexHarnessValidationError('Message page cursor is invalid.');
|
|
1278
1564
|
}
|
|
1279
|
-
const anchor =
|
|
1565
|
+
const anchor = visibleMessages.findIndex((message) => message.messageId === cursor.anchorMessageId);
|
|
1280
1566
|
if (anchor < 0) throw new FlexHarnessValidationError('Message page cursor is stale.');
|
|
1281
1567
|
end = anchor;
|
|
1282
1568
|
}
|
|
1283
1569
|
let start = end;
|
|
1284
1570
|
let messages: IFlexMessage[] = [];
|
|
1285
1571
|
for (let index = end - 1; index >= 0 && messages.length < limit; index--) {
|
|
1286
|
-
const candidate = createBoundedTransferMessage(
|
|
1572
|
+
const candidate = createBoundedTransferMessage(visibleMessages[index]);
|
|
1287
1573
|
const candidateMessages = [candidate, ...messages];
|
|
1288
1574
|
const nextCursor = index > 0
|
|
1289
1575
|
? this.createMessageCursor(namespace, sessionId, candidate.messageId)
|
|
@@ -1315,100 +1601,628 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1315
1601
|
return publicSnapshot(createBoundedTransferMessage(this.requireMessage(stored, messageId)));
|
|
1316
1602
|
}
|
|
1317
1603
|
|
|
1318
|
-
public async
|
|
1319
|
-
scopeId: string,
|
|
1320
|
-
sessionId: string,
|
|
1321
|
-
prompt: TFlexPrompt,
|
|
1322
|
-
options: IFlexPromptOptions = {},
|
|
1323
|
-
): Promise<IFlexPromptResult> {
|
|
1324
|
-
const admission = await this.startPrompt(scopeId, sessionId, prompt, options);
|
|
1325
|
-
return admission.completion;
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
public async startPrompt(
|
|
1604
|
+
public async listSlashCommands(
|
|
1329
1605
|
scopeId: string,
|
|
1330
1606
|
sessionId: string,
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1607
|
+
): Promise<IFlexSlashCommandDescriptor[]> {
|
|
1608
|
+
const { state } = await this.resolveState(scopeId);
|
|
1609
|
+
return this.trackSlashCommandListing(state, sessionId, async (signal) => {
|
|
1610
|
+
await this.awaitReversionOperation(state.scopeQueue, signal);
|
|
1611
|
+
signal.throwIfAborted();
|
|
1612
|
+
this.assertStateAcceptingWork(state);
|
|
1613
|
+
const stored = this.requireSession(state, sessionId);
|
|
1614
|
+
await this.reconcileContextAvailability(state, stored);
|
|
1615
|
+
signal.throwIfAborted();
|
|
1616
|
+
const sessionAvailable = this.isSessionAvailableForSlashCommand(state, stored);
|
|
1617
|
+
const reversionIdleReason = await this.reversionUnavailableReason(
|
|
1618
|
+
state,
|
|
1619
|
+
stored,
|
|
1620
|
+
false,
|
|
1621
|
+
signal,
|
|
1622
|
+
);
|
|
1623
|
+
signal.throwIfAborted();
|
|
1624
|
+
return this.createSlashCommandDescriptors(
|
|
1625
|
+
state,
|
|
1626
|
+
stored,
|
|
1627
|
+
sessionAvailable,
|
|
1628
|
+
reversionIdleReason,
|
|
1629
|
+
);
|
|
1630
|
+
});
|
|
1337
1631
|
}
|
|
1338
1632
|
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
):
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1633
|
+
private createSlashCommandDescriptors(
|
|
1634
|
+
state: IStorageState,
|
|
1635
|
+
stored: IStoredSessionState,
|
|
1636
|
+
sessionAvailable: boolean,
|
|
1637
|
+
reversionIdleReason?: string,
|
|
1638
|
+
): IFlexSlashCommandDescriptor[] {
|
|
1639
|
+
const sessionAvailability = sessionAvailable
|
|
1640
|
+
? {}
|
|
1641
|
+
: { available: false, unavailableReason: slashCommandSessionUnavailableReason };
|
|
1642
|
+
const undoUnit = this.reversionUnit(stored, 'undo');
|
|
1643
|
+
const redoUnit = this.reversionUnit(stored, 'redo');
|
|
1644
|
+
const workspaceReversion = (unit: ReturnType<typeof this.reversionUnit>) =>
|
|
1645
|
+
!unit
|
|
1646
|
+
|| !this.turnReversionProvider
|
|
1647
|
+
|| unit.segments.some((segment) => !segment.workspaceCaptured)
|
|
1648
|
+
? 'unsupported' as const
|
|
1649
|
+
: 'supported' as const;
|
|
1650
|
+
const reversionAvailability = (unit: ReturnType<typeof this.reversionUnit>) => {
|
|
1651
|
+
if (reversionIdleReason) return {
|
|
1652
|
+
available: false,
|
|
1653
|
+
unavailableReason: reversionIdleReason,
|
|
1654
|
+
};
|
|
1655
|
+
if (!unit) return { available: false, unavailableReason: 'No turn is available.' };
|
|
1656
|
+
if (!unit.target.contextAvailable) return {
|
|
1657
|
+
available: false,
|
|
1658
|
+
unavailableReason: 'The turn is beyond the context archive horizon.',
|
|
1659
|
+
};
|
|
1660
|
+
if (unit.segments.some((segment) =>
|
|
1661
|
+
segment.workspaceCaptured && segment.workspaceReference === undefined)) return {
|
|
1662
|
+
available: false,
|
|
1663
|
+
unavailableReason: 'The turn has no available workspace reversion capture.',
|
|
1664
|
+
};
|
|
1665
|
+
return { available: true };
|
|
1666
|
+
};
|
|
1667
|
+
const compactAvailable = Boolean(this.agentSessionPolicy.contextCompactor) && sessionAvailable;
|
|
1668
|
+
const compactUnavailableReason = !this.agentSessionPolicy.contextCompactor
|
|
1669
|
+
? 'No context compactor is configured.'
|
|
1670
|
+
: !sessionAvailable
|
|
1671
|
+
? slashCommandSessionUnavailableReason
|
|
1672
|
+
: undefined;
|
|
1673
|
+
const initPrompt = expandSlashCommandTemplate(FLEX_SLASH_COMMAND_INITIALIZE_TEMPLATE, '', []);
|
|
1674
|
+
const initAvailability = this.slashCommandPromptAvailability(
|
|
1675
|
+
state,
|
|
1676
|
+
stored,
|
|
1677
|
+
this.promptAdmissionByteSize(initPrompt, {}),
|
|
1678
|
+
);
|
|
1679
|
+
const descriptors: IFlexSlashCommandDescriptor[] = [
|
|
1680
|
+
{
|
|
1681
|
+
name: 'compact',
|
|
1682
|
+
description: 'Compact the current session context.',
|
|
1683
|
+
kind: 'builtin',
|
|
1684
|
+
hints: [],
|
|
1685
|
+
available: compactAvailable,
|
|
1686
|
+
...(compactUnavailableReason === undefined
|
|
1687
|
+
? {}
|
|
1688
|
+
: { unavailableReason: compactUnavailableReason }),
|
|
1689
|
+
workspaceReversion: 'not-applicable',
|
|
1690
|
+
},
|
|
1691
|
+
{
|
|
1692
|
+
name: 'init',
|
|
1693
|
+
description: 'Create or update AGENTS.md for the active workspace.',
|
|
1694
|
+
kind: 'builtin',
|
|
1695
|
+
hints: ['$ARGUMENTS'],
|
|
1696
|
+
...initAvailability,
|
|
1697
|
+
workspaceReversion: 'not-applicable',
|
|
1698
|
+
},
|
|
1699
|
+
{
|
|
1700
|
+
name: 'undo',
|
|
1701
|
+
description: 'Undo the most recent session operation.',
|
|
1702
|
+
kind: 'builtin',
|
|
1703
|
+
hints: [],
|
|
1704
|
+
...reversionAvailability(undoUnit),
|
|
1705
|
+
workspaceReversion: workspaceReversion(undoUnit),
|
|
1706
|
+
},
|
|
1707
|
+
{
|
|
1708
|
+
name: 'redo',
|
|
1709
|
+
description: 'Redo the most recently undone session operation.',
|
|
1710
|
+
kind: 'builtin',
|
|
1711
|
+
hints: [],
|
|
1712
|
+
...reversionAvailability(redoUnit),
|
|
1713
|
+
workspaceReversion: workspaceReversion(redoUnit),
|
|
1714
|
+
},
|
|
1715
|
+
];
|
|
1716
|
+
for (const registration of this.slashCommands.values()) {
|
|
1717
|
+
const template = typeof registration.template === 'string';
|
|
1718
|
+
const availability = template
|
|
1719
|
+
? this.slashCommandPromptAvailability(
|
|
1720
|
+
state,
|
|
1721
|
+
stored,
|
|
1722
|
+
this.promptAdmissionByteSize(
|
|
1723
|
+
expandSlashCommandTemplate(registration.template!, '', []),
|
|
1724
|
+
{},
|
|
1725
|
+
),
|
|
1726
|
+
)
|
|
1727
|
+
: { available: sessionAvailable, ...sessionAvailability };
|
|
1728
|
+
descriptors.push({
|
|
1729
|
+
name: registration.name,
|
|
1730
|
+
description: registration.description ?? '',
|
|
1731
|
+
kind: template ? 'template' : 'handler',
|
|
1732
|
+
hints: template ? slashCommandTemplateHints(registration.template!) : [],
|
|
1733
|
+
...availability,
|
|
1734
|
+
workspaceReversion: 'not-applicable',
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
return publicSnapshot(descriptors);
|
|
1348
1738
|
}
|
|
1349
1739
|
|
|
1350
|
-
public async
|
|
1740
|
+
public async executeSlashCommand(
|
|
1351
1741
|
scopeId: string,
|
|
1352
1742
|
sessionId: string,
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
const
|
|
1361
|
-
if (
|
|
1362
|
-
|
|
1363
|
-
|
|
1743
|
+
untouchedInput: string,
|
|
1744
|
+
options: IFlexSlashCommandExecutionOptions = {},
|
|
1745
|
+
): Promise<TFlexSlashCommandExecutionResult> {
|
|
1746
|
+
const currentInvocation = this.slashCommandInvocationContext.getStore();
|
|
1747
|
+
if (currentInvocation?.scopeId === scopeId && currentInvocation.sessionId === sessionId) {
|
|
1748
|
+
throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
|
|
1749
|
+
}
|
|
1750
|
+
const parsed = parseSlashCommand(untouchedInput);
|
|
1751
|
+
if (parsed.type !== 'parsed') return parsed;
|
|
1752
|
+
const registration = this.slashCommands.get(parsed.name);
|
|
1753
|
+
if (!reservedSlashCommandNames.has(parsed.name) && !registration) {
|
|
1754
|
+
return Object.freeze({
|
|
1755
|
+
type: 'unknown',
|
|
1756
|
+
input: parsed.input,
|
|
1757
|
+
name: parsed.name,
|
|
1758
|
+
rawArguments: parsed.rawArguments,
|
|
1759
|
+
arguments: parsed.arguments,
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
const promptOptions = validateSlashCommandExecutionOptions(options);
|
|
1763
|
+
const { scope, state } = await this.resolveState(scopeId);
|
|
1764
|
+
await state.scopeQueue;
|
|
1765
|
+
this.assertOpen();
|
|
1766
|
+
this.assertStateAcceptingWork(state);
|
|
1767
|
+
const stored = this.requireSession(state, sessionId);
|
|
1768
|
+
const sessionKey = this.slashCommandSessionKey(state.storageKey, sessionId);
|
|
1769
|
+
const invocationOwner = this.slashCommandInvocationContext.getStore();
|
|
1770
|
+
if (invocationOwner?.sessionKey === sessionKey) {
|
|
1771
|
+
throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
|
|
1772
|
+
}
|
|
1773
|
+
if (this.activeSlashCommandExecutions.has(sessionKey)) {
|
|
1774
|
+
throw new FlexHarnessSessionBusyError(
|
|
1775
|
+
sessionId,
|
|
1776
|
+
'already has an active slash command execution',
|
|
1364
1777
|
);
|
|
1365
1778
|
}
|
|
1366
|
-
|
|
1779
|
+
if (parsed.name === 'undo' || parsed.name === 'redo') {
|
|
1780
|
+
if (parsed.arguments.length > 0) {
|
|
1781
|
+
throw new FlexHarnessValidationError(`Slash command "/${parsed.name}" does not accept arguments.`);
|
|
1782
|
+
}
|
|
1783
|
+
await this.changeReversionCursorResolved(
|
|
1784
|
+
scopeId,
|
|
1785
|
+
sessionId,
|
|
1786
|
+
parsed.name,
|
|
1787
|
+
scope.scope,
|
|
1788
|
+
state,
|
|
1789
|
+
options.signal,
|
|
1790
|
+
);
|
|
1791
|
+
return Object.freeze({ type: 'operation', name: parsed.name });
|
|
1792
|
+
}
|
|
1793
|
+
if (parsed.name === 'compact') {
|
|
1794
|
+
if (parsed.arguments.length > 0) {
|
|
1795
|
+
throw new FlexHarnessValidationError('Slash command "/compact" does not accept arguments.');
|
|
1796
|
+
}
|
|
1797
|
+
if (!this.agentSessionPolicy.contextCompactor) {
|
|
1798
|
+
throw new FlexHarnessSlashCommandUnavailableError(
|
|
1799
|
+
parsed.name,
|
|
1800
|
+
'No context compactor is configured.',
|
|
1801
|
+
);
|
|
1802
|
+
}
|
|
1803
|
+
this.requireSessionAvailableForSlashCommand(state, stored, parsed.name);
|
|
1804
|
+
return this.trackSlashCommandExecution(
|
|
1805
|
+
state,
|
|
1806
|
+
sessionId,
|
|
1807
|
+
'operation',
|
|
1808
|
+
options.signal,
|
|
1809
|
+
async (signal) => {
|
|
1810
|
+
await this.commitRevertedBranch(state, stored, scopeId, scope.scope);
|
|
1811
|
+
await this.compactStoredSession(scopeId, scope.scope, state, stored, signal);
|
|
1812
|
+
return Object.freeze({ type: 'operation', name: parsed.name });
|
|
1813
|
+
},
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
if (options.signal?.aborted) {
|
|
1817
|
+
throw this.trustInternalError(new FlexHarnessAbortError('The slash command was aborted.'));
|
|
1818
|
+
}
|
|
1819
|
+
if (parsed.name === 'init' || typeof registration?.template === 'string') {
|
|
1820
|
+
const template = parsed.name === 'init'
|
|
1821
|
+
? FLEX_SLASH_COMMAND_INITIALIZE_TEMPLATE
|
|
1822
|
+
: registration!.template!;
|
|
1823
|
+
const expanded = expandSlashCommandTemplate(template, parsed.rawArguments, parsed.arguments);
|
|
1824
|
+
normalizeFlexPrompt(expanded);
|
|
1825
|
+
this.requireSlashCommandPromptAvailable(
|
|
1826
|
+
state,
|
|
1827
|
+
stored,
|
|
1828
|
+
parsed.name,
|
|
1829
|
+
this.promptAdmissionByteSize(expanded, promptOptions),
|
|
1830
|
+
);
|
|
1831
|
+
return this.trackSlashCommandExecution(
|
|
1832
|
+
state,
|
|
1833
|
+
sessionId,
|
|
1834
|
+
'prompt-admission',
|
|
1835
|
+
options.signal,
|
|
1836
|
+
async (signal) => {
|
|
1837
|
+
const queued = await this.enqueuePromptInternal(
|
|
1838
|
+
scopeId,
|
|
1839
|
+
sessionId,
|
|
1840
|
+
expanded,
|
|
1841
|
+
promptOptions,
|
|
1842
|
+
undefined,
|
|
1843
|
+
undefined,
|
|
1844
|
+
false,
|
|
1845
|
+
signal,
|
|
1846
|
+
true,
|
|
1847
|
+
);
|
|
1848
|
+
const cancellation = this.trustInternalError(
|
|
1849
|
+
new FlexHarnessAbortError('The slash command prompt was aborted.'),
|
|
1850
|
+
);
|
|
1851
|
+
const cancel = () => {
|
|
1852
|
+
const entry = queued.queued;
|
|
1853
|
+
if (!entry) return;
|
|
1854
|
+
this.cancelQueuedPrompt(
|
|
1855
|
+
entry,
|
|
1856
|
+
cancellation,
|
|
1857
|
+
this.createCompactorContext(scopeId, scope.scope, state.storageKey, sessionId),
|
|
1858
|
+
);
|
|
1859
|
+
};
|
|
1860
|
+
const cancellationSignals = options.signal && options.signal !== signal
|
|
1861
|
+
? [signal, options.signal]
|
|
1862
|
+
: [signal];
|
|
1863
|
+
for (const cancellationSignal of cancellationSignals) {
|
|
1864
|
+
cancellationSignal.addEventListener('abort', cancel, { once: true });
|
|
1865
|
+
}
|
|
1866
|
+
if (cancellationSignals.some((cancellationSignal) => cancellationSignal.aborted)) cancel();
|
|
1867
|
+
let admission: IFlexPromptAdmission;
|
|
1868
|
+
try {
|
|
1869
|
+
admission = await queued.started;
|
|
1870
|
+
if (cancellationSignals.some((cancellationSignal) => cancellationSignal.aborted)) {
|
|
1871
|
+
cancel();
|
|
1872
|
+
throw cancellationSignals.find((cancellationSignal) => cancellationSignal.aborted)?.reason
|
|
1873
|
+
?? cancellation;
|
|
1874
|
+
}
|
|
1875
|
+
} catch (error) {
|
|
1876
|
+
for (const cancellationSignal of cancellationSignals) {
|
|
1877
|
+
cancellationSignal.removeEventListener('abort', cancel);
|
|
1878
|
+
}
|
|
1879
|
+
throw error;
|
|
1880
|
+
}
|
|
1881
|
+
void admission.completion.finally(() => {
|
|
1882
|
+
for (const cancellationSignal of cancellationSignals) {
|
|
1883
|
+
cancellationSignal.removeEventListener('abort', cancel);
|
|
1884
|
+
}
|
|
1885
|
+
}).catch(() => undefined);
|
|
1886
|
+
return Object.freeze({ type: 'prompt-admission', name: parsed.name, admission });
|
|
1887
|
+
},
|
|
1888
|
+
);
|
|
1889
|
+
}
|
|
1890
|
+
this.requireSessionAvailableForSlashCommand(state, stored, parsed.name);
|
|
1891
|
+
return this.executeSlashCommandHandler(
|
|
1367
1892
|
scopeId,
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1893
|
+
scope.scope,
|
|
1894
|
+
state,
|
|
1895
|
+
stored,
|
|
1896
|
+
parsed.name,
|
|
1897
|
+
parsed.rawArguments,
|
|
1898
|
+
parsed.arguments,
|
|
1899
|
+
registration!.handler!,
|
|
1900
|
+
options.signal,
|
|
1373
1901
|
);
|
|
1374
|
-
const admission = await queued.started;
|
|
1375
|
-
return Object.freeze({ ...admission, scheduleKey });
|
|
1376
1902
|
}
|
|
1377
1903
|
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
):
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
if (!entry) throw new FlexHarnessNotFoundError('Prompt queue entry', queueId);
|
|
1390
|
-
return publicSnapshot(entry);
|
|
1904
|
+
private isSessionAvailableForSlashCommand(
|
|
1905
|
+
state: IStorageState,
|
|
1906
|
+
stored: IStoredSessionState,
|
|
1907
|
+
allowPendingApply = false,
|
|
1908
|
+
): boolean {
|
|
1909
|
+
return this.isSessionRuntimeIdle(state, stored)
|
|
1910
|
+
&& !this.activeSlashCommandExecutions.has(
|
|
1911
|
+
this.slashCommandSessionKey(state.storageKey, stored.session.sessionId),
|
|
1912
|
+
)
|
|
1913
|
+
&& (stored.pendingReversion === undefined
|
|
1914
|
+
|| (allowPendingApply && stored.pendingReversion.kind === 'apply'));
|
|
1391
1915
|
}
|
|
1392
1916
|
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1917
|
+
private slashCommandPromptUnavailableReason(
|
|
1918
|
+
state: IStorageState,
|
|
1919
|
+
stored: IStoredSessionState,
|
|
1920
|
+
byteSize: number,
|
|
1921
|
+
): string | undefined {
|
|
1922
|
+
if (state.lifecycle !== 'active') return 'The session namespace is not accepting work.';
|
|
1923
|
+
if (this.activeSlashCommandExecutions.has(
|
|
1924
|
+
this.slashCommandSessionKey(state.storageKey, stored.session.sessionId),
|
|
1925
|
+
)) return 'Session already has an active slash command execution.';
|
|
1926
|
+
if (stored.pendingReversion) return 'Session has a pending reversion operation.';
|
|
1927
|
+
if (stored.session.agent !== undefined) {
|
|
1928
|
+
return 'Subagent sessions can only be prompted through the foreground task tool.';
|
|
1929
|
+
}
|
|
1930
|
+
if (stored.outstandingPromptsById.size >= this.promptQueueLimits.maxOutstandingPromptsPerSession) {
|
|
1931
|
+
return 'Session has reached its outstanding prompt limit.';
|
|
1932
|
+
}
|
|
1933
|
+
if (stored.outstandingPromptBytes + byteSize > this.promptQueueLimits.maxOutstandingBytesPerSession) {
|
|
1934
|
+
return 'Session has reached its outstanding prompt byte limit.';
|
|
1935
|
+
}
|
|
1936
|
+
if (this.pendingPromptAdmissions >= this.promptQueueLimits.maxPendingAdmissions) {
|
|
1937
|
+
return 'FlexHarness has reached its pending prompt admission limit.';
|
|
1938
|
+
}
|
|
1939
|
+
if (this.pendingPromptAdmissionBytes + byteSize > this.promptQueueLimits.maxPendingAdmissionBytes) {
|
|
1940
|
+
return 'FlexHarness has reached its pending prompt admission byte limit.';
|
|
1941
|
+
}
|
|
1942
|
+
return undefined;
|
|
1404
1943
|
}
|
|
1405
1944
|
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
):
|
|
1411
|
-
|
|
1945
|
+
private slashCommandPromptAvailability(
|
|
1946
|
+
state: IStorageState,
|
|
1947
|
+
stored: IStoredSessionState,
|
|
1948
|
+
byteSize: number,
|
|
1949
|
+
): { available: boolean; unavailableReason?: string } {
|
|
1950
|
+
const unavailableReason = this.slashCommandPromptUnavailableReason(state, stored, byteSize);
|
|
1951
|
+
return unavailableReason === undefined
|
|
1952
|
+
? { available: true }
|
|
1953
|
+
: { available: false, unavailableReason };
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
private requireSlashCommandPromptAvailable(
|
|
1957
|
+
state: IStorageState,
|
|
1958
|
+
stored: IStoredSessionState,
|
|
1959
|
+
commandName: string,
|
|
1960
|
+
byteSize: number,
|
|
1961
|
+
): void {
|
|
1962
|
+
const reason = this.slashCommandPromptUnavailableReason(state, stored, byteSize);
|
|
1963
|
+
if (reason) throw new FlexHarnessSlashCommandUnavailableError(commandName, reason);
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
private promptAdmissionByteSize(
|
|
1967
|
+
prompt: TFlexPrompt,
|
|
1968
|
+
options: IFlexPromptOptions,
|
|
1969
|
+
scheduleKey?: string,
|
|
1970
|
+
debounceMs?: number,
|
|
1971
|
+
): number {
|
|
1972
|
+
return jsonBytes({
|
|
1973
|
+
prompt: normalizeFlexPrompt(prompt),
|
|
1974
|
+
options: cloneSerializable(options),
|
|
1975
|
+
scheduleKey,
|
|
1976
|
+
debounceMs,
|
|
1977
|
+
});
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
private isSessionRuntimeIdle(state: IStorageState, stored: IStoredSessionState): boolean {
|
|
1981
|
+
return stored.session.status === 'idle'
|
|
1982
|
+
&& !state.activeRuns.has(stored.session.sessionId)
|
|
1983
|
+
&& stored.outstandingPromptsById.size === 0;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
private slashCommandSessionKey(storageKey: string, sessionId: string): string {
|
|
1987
|
+
return JSON.stringify([storageKey, sessionId]);
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
private requireSessionAvailableForSlashCommand(
|
|
1991
|
+
state: IStorageState,
|
|
1992
|
+
stored: IStoredSessionState,
|
|
1993
|
+
commandName: string,
|
|
1994
|
+
): void {
|
|
1995
|
+
if (!this.isSessionAvailableForSlashCommand(state, stored)) {
|
|
1996
|
+
throw new FlexHarnessSlashCommandUnavailableError(
|
|
1997
|
+
commandName,
|
|
1998
|
+
slashCommandSessionUnavailableReason,
|
|
1999
|
+
);
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
private trackSlashCommandExecution<TResult>(
|
|
2004
|
+
state: IStorageState,
|
|
2005
|
+
sessionId: string,
|
|
2006
|
+
kind: IActiveSlashCommandExecution['kind'],
|
|
2007
|
+
externalSignal: AbortSignal | undefined,
|
|
2008
|
+
operation: (signal: AbortSignal) => Promise<TResult>,
|
|
2009
|
+
): Promise<TResult> {
|
|
2010
|
+
const sessionKey = this.slashCommandSessionKey(state.storageKey, sessionId);
|
|
2011
|
+
if (this.activeSlashCommandExecutions.has(sessionKey)) {
|
|
2012
|
+
throw new FlexHarnessSessionBusyError(
|
|
2013
|
+
sessionId,
|
|
2014
|
+
'already has an active slash command execution',
|
|
2015
|
+
);
|
|
2016
|
+
}
|
|
2017
|
+
const controller = new AbortController();
|
|
2018
|
+
const abortFromExternalSignal = () => {
|
|
2019
|
+
if (!controller.signal.aborted) {
|
|
2020
|
+
controller.abort(this.trustInternalError(
|
|
2021
|
+
new FlexHarnessAbortError('The slash command was aborted.'),
|
|
2022
|
+
));
|
|
2023
|
+
}
|
|
2024
|
+
};
|
|
2025
|
+
externalSignal?.addEventListener('abort', abortFromExternalSignal, { once: true });
|
|
2026
|
+
if (externalSignal?.aborted) abortFromExternalSignal();
|
|
2027
|
+
const completion = Promise.resolve().then(() => {
|
|
2028
|
+
if (controller.signal.aborted) throw controller.signal.reason;
|
|
2029
|
+
return operation(controller.signal);
|
|
2030
|
+
});
|
|
2031
|
+
const active: IActiveSlashCommandExecution = {
|
|
2032
|
+
kind,
|
|
2033
|
+
storageKey: state.storageKey,
|
|
2034
|
+
sessionId,
|
|
2035
|
+
controller,
|
|
2036
|
+
completion,
|
|
2037
|
+
};
|
|
2038
|
+
this.activeSlashCommandExecutions.set(sessionKey, active);
|
|
2039
|
+
return completion.finally(() => {
|
|
2040
|
+
externalSignal?.removeEventListener('abort', abortFromExternalSignal);
|
|
2041
|
+
if (this.activeSlashCommandExecutions.get(sessionKey) === active) {
|
|
2042
|
+
this.activeSlashCommandExecutions.delete(sessionKey);
|
|
2043
|
+
}
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
private trackSlashCommandListing<TResult>(
|
|
2048
|
+
state: IStorageState,
|
|
2049
|
+
sessionId: string,
|
|
2050
|
+
operation: (signal: AbortSignal) => Promise<TResult>,
|
|
2051
|
+
): Promise<TResult> {
|
|
2052
|
+
const controller = new AbortController();
|
|
2053
|
+
const completion = Promise.resolve().then(() => operation(controller.signal));
|
|
2054
|
+
const active: IActiveSlashCommandListing = {
|
|
2055
|
+
storageKey: state.storageKey,
|
|
2056
|
+
sessionId,
|
|
2057
|
+
controller,
|
|
2058
|
+
completion,
|
|
2059
|
+
};
|
|
2060
|
+
this.activeSlashCommandListings.add(active);
|
|
2061
|
+
return completion.finally(() => {
|
|
2062
|
+
this.activeSlashCommandListings.delete(active);
|
|
2063
|
+
});
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
private async executeSlashCommandHandler(
|
|
2067
|
+
scopeId: string,
|
|
2068
|
+
scope: TScope,
|
|
2069
|
+
state: IStorageState,
|
|
2070
|
+
stored: IStoredSessionState,
|
|
2071
|
+
commandName: string,
|
|
2072
|
+
rawArguments: string,
|
|
2073
|
+
arguments_: readonly string[],
|
|
2074
|
+
handler: IFlexSlashCommandHandlerRegistration<TScope>['handler'],
|
|
2075
|
+
externalSignal?: AbortSignal,
|
|
2076
|
+
): Promise<TFlexSlashCommandExecutionResult> {
|
|
2077
|
+
const sessionKey = this.slashCommandSessionKey(state.storageKey, stored.session.sessionId);
|
|
2078
|
+
const invocationOwner = this.slashCommandInvocationContext.getStore();
|
|
2079
|
+
if (invocationOwner?.sessionKey === sessionKey) {
|
|
2080
|
+
throw this.trustInternalError(
|
|
2081
|
+
new FlexHarnessSlashCommandReentryError(stored.session.sessionId),
|
|
2082
|
+
);
|
|
2083
|
+
}
|
|
2084
|
+
if (this.activeSlashCommandExecutions.has(sessionKey)) {
|
|
2085
|
+
throw new FlexHarnessSessionBusyError(
|
|
2086
|
+
stored.session.sessionId,
|
|
2087
|
+
'already has an active slash command execution',
|
|
2088
|
+
);
|
|
2089
|
+
}
|
|
2090
|
+
const owner = Object.freeze({
|
|
2091
|
+
scopeId,
|
|
2092
|
+
storageKey: state.storageKey,
|
|
2093
|
+
sessionId: stored.session.sessionId,
|
|
2094
|
+
sessionKey,
|
|
2095
|
+
});
|
|
2096
|
+
return this.trackSlashCommandExecution(
|
|
2097
|
+
state,
|
|
2098
|
+
stored.session.sessionId,
|
|
2099
|
+
'handler',
|
|
2100
|
+
externalSignal,
|
|
2101
|
+
(signal) => this.slashCommandInvocationContext.run(owner, async () => {
|
|
2102
|
+
const context = Object.freeze({
|
|
2103
|
+
scopeId,
|
|
2104
|
+
scope,
|
|
2105
|
+
storageKey: state.storageKey,
|
|
2106
|
+
sessionId: stored.session.sessionId,
|
|
2107
|
+
rawArguments,
|
|
2108
|
+
arguments: arguments_,
|
|
2109
|
+
signal,
|
|
2110
|
+
});
|
|
2111
|
+
try {
|
|
2112
|
+
await this.commitRevertedBranch(state, stored, scopeId, scope);
|
|
2113
|
+
const result = await handler(context);
|
|
2114
|
+
return Object.freeze({
|
|
2115
|
+
type: 'handler-result' as const,
|
|
2116
|
+
name: commandName,
|
|
2117
|
+
result: normalizeJsonValue(result === undefined ? null : result, this.toolOutputLimits),
|
|
2118
|
+
});
|
|
2119
|
+
} catch (error) {
|
|
2120
|
+
throw this.projectOperationError(
|
|
2121
|
+
error,
|
|
2122
|
+
'slashCommand',
|
|
2123
|
+
scopeId,
|
|
2124
|
+
stored.session.sessionId,
|
|
2125
|
+
`slash-command:${commandName}`,
|
|
2126
|
+
);
|
|
2127
|
+
}
|
|
2128
|
+
}),
|
|
2129
|
+
);
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
public async prompt(
|
|
2133
|
+
scopeId: string,
|
|
2134
|
+
sessionId: string,
|
|
2135
|
+
prompt: TFlexPrompt,
|
|
2136
|
+
options: IFlexPromptOptions = {},
|
|
2137
|
+
): Promise<IFlexPromptResult> {
|
|
2138
|
+
const admission = await this.startPrompt(scopeId, sessionId, prompt, options);
|
|
2139
|
+
return admission.completion;
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
public async startPrompt(
|
|
2143
|
+
scopeId: string,
|
|
2144
|
+
sessionId: string,
|
|
2145
|
+
prompt: TFlexPrompt,
|
|
2146
|
+
options: IFlexPromptOptions = {},
|
|
2147
|
+
): Promise<IFlexPromptAdmission> {
|
|
2148
|
+
validatePromptOptions(options, false);
|
|
2149
|
+
const queued = await this.enqueuePromptInternal(scopeId, sessionId, prompt, options);
|
|
2150
|
+
return queued.started;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
public async enqueuePrompt(
|
|
2154
|
+
scopeId: string,
|
|
2155
|
+
sessionId: string,
|
|
2156
|
+
prompt: TFlexPrompt,
|
|
2157
|
+
options: IFlexPromptOptions = {},
|
|
2158
|
+
): Promise<IFlexPromptQueueAdmission> {
|
|
2159
|
+
validatePromptOptions(options, false);
|
|
2160
|
+
const queued = await this.enqueuePromptInternal(scopeId, sessionId, prompt, options);
|
|
2161
|
+
return queued.admission;
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
public async schedulePrompt(
|
|
2165
|
+
scopeId: string,
|
|
2166
|
+
sessionId: string,
|
|
2167
|
+
scheduleKey: string,
|
|
2168
|
+
prompt: TFlexPrompt,
|
|
2169
|
+
options: IFlexSchedulePromptOptions = {},
|
|
2170
|
+
): Promise<IFlexScheduledPromptAdmission> {
|
|
2171
|
+
validateIdentifier(scheduleKey, 'scheduleKey');
|
|
2172
|
+
requireTransferIdentifier(scheduleKey, 'scheduleKey');
|
|
2173
|
+
validatePromptOptions(options, true);
|
|
2174
|
+
const debounceMs = options.debounceMs ?? 50;
|
|
2175
|
+
if (!Number.isSafeInteger(debounceMs) || debounceMs < 0 || debounceMs > maxScheduleDebounceMs) {
|
|
2176
|
+
throw new FlexHarnessValidationError(
|
|
2177
|
+
`debounceMs must be an integer from 0 through ${maxScheduleDebounceMs}.`,
|
|
2178
|
+
);
|
|
2179
|
+
}
|
|
2180
|
+
const queued = await this.enqueuePromptInternal(
|
|
2181
|
+
scopeId,
|
|
2182
|
+
sessionId,
|
|
2183
|
+
prompt,
|
|
2184
|
+
options,
|
|
2185
|
+
scheduleKey,
|
|
2186
|
+
debounceMs,
|
|
2187
|
+
);
|
|
2188
|
+
const admission = await queued.started;
|
|
2189
|
+
return Object.freeze({ ...admission, scheduleKey });
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
public async getPromptQueueEntry(
|
|
2193
|
+
scopeId: string,
|
|
2194
|
+
sessionId: string,
|
|
2195
|
+
queueId: string,
|
|
2196
|
+
): Promise<IFlexPromptQueueEntry> {
|
|
2197
|
+
validateIdentifier(queueId, 'queueId');
|
|
2198
|
+
requireTransferIdentifier(queueId, 'queueId');
|
|
2199
|
+
const { state } = await this.resolveState(scopeId);
|
|
2200
|
+
this.assertStateAcceptingWork(state);
|
|
2201
|
+
const stored = this.requireSession(state, sessionId);
|
|
2202
|
+
const entry = this.promptQueueEntry(stored, queueId);
|
|
2203
|
+
if (!entry) throw new FlexHarnessNotFoundError('Prompt queue entry', queueId);
|
|
2204
|
+
return publicSnapshot(entry);
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
public async listPromptQueueEntries(
|
|
2208
|
+
scopeId: string,
|
|
2209
|
+
sessionId: string,
|
|
2210
|
+
): Promise<IFlexPromptQueueEntry[]> {
|
|
2211
|
+
const { state } = await this.resolveState(scopeId);
|
|
2212
|
+
this.assertStateAcceptingWork(state);
|
|
2213
|
+
const stored = this.requireSession(state, sessionId);
|
|
2214
|
+
return publicSnapshot([
|
|
2215
|
+
...[...stored.outstandingPromptsById.values()].map((entry) => this.projectPromptQueueEntry(entry)),
|
|
2216
|
+
...stored.terminalPromptQueueEntries.values(),
|
|
2217
|
+
].sort((left, right) => left.queueSequence - right.queueSequence));
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
public async cancelPrompt(
|
|
2221
|
+
scopeId: string,
|
|
2222
|
+
sessionId: string,
|
|
2223
|
+
queueId: string,
|
|
2224
|
+
): Promise<boolean> {
|
|
2225
|
+
validateIdentifier(queueId, 'queueId');
|
|
1412
2226
|
requireTransferIdentifier(queueId, 'queueId');
|
|
1413
2227
|
const { scope, state } = await this.resolveState(scopeId);
|
|
1414
2228
|
const stored = this.requireSession(state, sessionId);
|
|
@@ -1576,15 +2390,505 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1576
2390
|
public async compactSession(scopeId: string, sessionId: string): Promise<void> {
|
|
1577
2391
|
const { scope, state } = await this.resolveState(scopeId);
|
|
1578
2392
|
const stored = this.requireSession(state, sessionId);
|
|
2393
|
+
const invocationOwner = this.slashCommandInvocationContext.getStore();
|
|
2394
|
+
if (invocationOwner?.sessionKey === this.slashCommandSessionKey(state.storageKey, sessionId)) {
|
|
2395
|
+
throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
|
|
2396
|
+
}
|
|
1579
2397
|
this.assertStateAcceptingWork(state);
|
|
2398
|
+
if (!this.agentSessionPolicy.contextCompactor) {
|
|
2399
|
+
throw new FlexHarnessSlashCommandUnavailableError(
|
|
2400
|
+
'compact',
|
|
2401
|
+
'No context compactor is configured.',
|
|
2402
|
+
);
|
|
2403
|
+
}
|
|
2404
|
+
this.requireSessionAvailableForSlashCommand(state, stored, 'compact');
|
|
2405
|
+
await this.trackSlashCommandExecution(
|
|
2406
|
+
state,
|
|
2407
|
+
sessionId,
|
|
2408
|
+
'operation',
|
|
2409
|
+
undefined,
|
|
2410
|
+
async (signal) => {
|
|
2411
|
+
await this.commitRevertedBranch(state, stored, scopeId, scope.scope);
|
|
2412
|
+
await this.compactStoredSession(scopeId, scope.scope, state, stored, signal);
|
|
2413
|
+
},
|
|
2414
|
+
);
|
|
2415
|
+
}
|
|
2416
|
+
|
|
2417
|
+
private async compactStoredSession(
|
|
2418
|
+
scopeId: string,
|
|
2419
|
+
scope: TScope,
|
|
2420
|
+
state: IStorageState,
|
|
2421
|
+
stored: IStoredSessionState,
|
|
2422
|
+
signal?: AbortSignal,
|
|
2423
|
+
): Promise<void> {
|
|
1580
2424
|
try {
|
|
1581
2425
|
await this.withCompactorContext(
|
|
1582
|
-
this.createCompactorContext(
|
|
1583
|
-
|
|
2426
|
+
this.createCompactorContext(
|
|
2427
|
+
scopeId,
|
|
2428
|
+
scope,
|
|
2429
|
+
state.storageKey,
|
|
2430
|
+
stored.session.sessionId,
|
|
2431
|
+
),
|
|
2432
|
+
() => stored.agentSession.compact(signal === undefined ? {} : { abort: signal }),
|
|
1584
2433
|
);
|
|
1585
2434
|
} catch (error) {
|
|
1586
|
-
throw this.projectOperationError(
|
|
2435
|
+
throw this.projectOperationError(
|
|
2436
|
+
error,
|
|
2437
|
+
'agentSession',
|
|
2438
|
+
scopeId,
|
|
2439
|
+
stored.session.sessionId,
|
|
2440
|
+
'compaction',
|
|
2441
|
+
);
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
public async undoSession(
|
|
2446
|
+
scopeId: string,
|
|
2447
|
+
sessionId: string,
|
|
2448
|
+
signal?: AbortSignal,
|
|
2449
|
+
): Promise<IFlexUndoSessionResult> {
|
|
2450
|
+
const result = await this.changeReversionCursor(scopeId, sessionId, 'undo', signal);
|
|
2451
|
+
return Object.freeze({ revertedRunId: result });
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
public async redoSession(
|
|
2455
|
+
scopeId: string,
|
|
2456
|
+
sessionId: string,
|
|
2457
|
+
signal?: AbortSignal,
|
|
2458
|
+
): Promise<IFlexRedoSessionResult> {
|
|
2459
|
+
const result = await this.changeReversionCursor(scopeId, sessionId, 'redo', signal);
|
|
2460
|
+
return Object.freeze({ restoredRunId: result });
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
private reversionUnit(
|
|
2464
|
+
stored: IStoredSessionState,
|
|
2465
|
+
direction: 'undo' | 'redo',
|
|
2466
|
+
): { target: IFlexReversionSegment; segments: IFlexReversionSegment[]; toCursor: number } | undefined {
|
|
2467
|
+
const candidates = this.completedReversionCandidates(stored);
|
|
2468
|
+
const candidate = direction === 'undo'
|
|
2469
|
+
? candidates[stored.revertCursor - 1]
|
|
2470
|
+
: candidates[stored.revertCursor];
|
|
2471
|
+
if (!candidate) return undefined;
|
|
2472
|
+
const candidateIndex = stored.reversionSegments.findIndex((segment) => segment.runId === candidate.runId);
|
|
2473
|
+
const start = candidate === candidates[0] ? 0 : candidateIndex;
|
|
2474
|
+
const next = candidates[direction === 'undo' ? stored.revertCursor : stored.revertCursor + 1];
|
|
2475
|
+
const end = next
|
|
2476
|
+
? stored.reversionSegments.findIndex((segment) => segment.runId === next.runId)
|
|
2477
|
+
: stored.reversionSegments.length;
|
|
2478
|
+
return {
|
|
2479
|
+
target: candidate,
|
|
2480
|
+
segments: stored.reversionSegments.slice(start, end),
|
|
2481
|
+
toCursor: stored.revertCursor + (direction === 'undo' ? -1 : 1),
|
|
2482
|
+
};
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
private async requireReversionIdle(
|
|
2486
|
+
state: IStorageState,
|
|
2487
|
+
stored: IStoredSessionState,
|
|
2488
|
+
sessionId: string,
|
|
2489
|
+
allowPendingApply = false,
|
|
2490
|
+
operationSignal?: AbortSignal,
|
|
2491
|
+
): Promise<void> {
|
|
2492
|
+
const reason = await this.reversionUnavailableReason(
|
|
2493
|
+
state,
|
|
2494
|
+
stored,
|
|
2495
|
+
allowPendingApply,
|
|
2496
|
+
operationSignal,
|
|
2497
|
+
);
|
|
2498
|
+
if (reason) throw new FlexHarnessSessionBusyError(sessionId, reason.toLowerCase().replace(/\.$/u, ''));
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
private async reversionUnavailableReason(
|
|
2502
|
+
state: IStorageState,
|
|
2503
|
+
stored: IStoredSessionState,
|
|
2504
|
+
allowPendingApply = false,
|
|
2505
|
+
operationSignal?: AbortSignal,
|
|
2506
|
+
): Promise<string | undefined> {
|
|
2507
|
+
if (stored.pendingReversion && !(allowPendingApply && stored.pendingReversion.kind === 'apply')) {
|
|
2508
|
+
return 'Session has a pending reversion operation.';
|
|
2509
|
+
}
|
|
2510
|
+
if (!this.isSessionRuntimeIdle(state, stored)) {
|
|
2511
|
+
return slashCommandSessionUnavailableReason;
|
|
2512
|
+
}
|
|
2513
|
+
const activeOperation = this.activeSlashCommandExecutions.get(
|
|
2514
|
+
this.slashCommandSessionKey(state.storageKey, stored.session.sessionId),
|
|
2515
|
+
);
|
|
2516
|
+
if (activeOperation && activeOperation.controller.signal !== operationSignal) {
|
|
2517
|
+
return 'Session already has an active slash command execution.';
|
|
2518
|
+
}
|
|
2519
|
+
if ([...state.pendingPermissions.values()].some((pending) =>
|
|
2520
|
+
!pending.settled && pending.request.sessionId === stored.session.sessionId)) {
|
|
2521
|
+
return 'Session has a pending permission.';
|
|
2522
|
+
}
|
|
2523
|
+
if (stored.agentSession.listUncertainToolExecutions().length > 0) {
|
|
2524
|
+
return 'Session has uncertain tool executions.';
|
|
2525
|
+
}
|
|
2526
|
+
if (stored.jobs) {
|
|
2527
|
+
operationSignal?.throwIfAborted();
|
|
2528
|
+
const jobs = Promise.resolve().then(() => stored.jobs!.list());
|
|
2529
|
+
if ((await this.awaitReversionOperation(jobs, operationSignal))
|
|
2530
|
+
.some((job) => job.state === 'running')) {
|
|
2531
|
+
return 'Session has running background jobs.';
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
return undefined;
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
private async awaitReversionOperation<TResult>(
|
|
2538
|
+
operation: Promise<TResult>,
|
|
2539
|
+
signal?: AbortSignal,
|
|
2540
|
+
): Promise<TResult> {
|
|
2541
|
+
if (!signal) return operation;
|
|
2542
|
+
void operation.catch(() => undefined);
|
|
2543
|
+
let rejectForAbort!: () => void;
|
|
2544
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
2545
|
+
rejectForAbort = () => reject(
|
|
2546
|
+
signal.reason ?? this.trustInternalError(new FlexHarnessAbortError('The slash command was aborted.')),
|
|
2547
|
+
);
|
|
2548
|
+
signal.addEventListener('abort', rejectForAbort, { once: true });
|
|
2549
|
+
if (signal.aborted) rejectForAbort();
|
|
2550
|
+
});
|
|
2551
|
+
try {
|
|
2552
|
+
return await Promise.race([operation, aborted]);
|
|
2553
|
+
} finally {
|
|
2554
|
+
signal.removeEventListener('abort', rejectForAbort);
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
private async changeReversionCursor(
|
|
2559
|
+
scopeId: string,
|
|
2560
|
+
sessionId: string,
|
|
2561
|
+
direction: 'undo' | 'redo',
|
|
2562
|
+
signal?: AbortSignal,
|
|
2563
|
+
): Promise<string> {
|
|
2564
|
+
const { scope, state } = await this.resolveState(scopeId);
|
|
2565
|
+
return this.changeReversionCursorResolved(
|
|
2566
|
+
scopeId,
|
|
2567
|
+
sessionId,
|
|
2568
|
+
direction,
|
|
2569
|
+
scope.scope,
|
|
2570
|
+
state,
|
|
2571
|
+
signal,
|
|
2572
|
+
);
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
private async changeReversionCursorResolved(
|
|
2576
|
+
scopeId: string,
|
|
2577
|
+
sessionId: string,
|
|
2578
|
+
direction: 'undo' | 'redo',
|
|
2579
|
+
scope: TScope,
|
|
2580
|
+
state: IStorageState,
|
|
2581
|
+
signal?: AbortSignal,
|
|
2582
|
+
): Promise<string> {
|
|
2583
|
+
const stored = this.requireSession(state, sessionId);
|
|
2584
|
+
this.assertStateAcceptingWork(state);
|
|
2585
|
+
const invocationOwner = this.slashCommandInvocationContext.getStore();
|
|
2586
|
+
if (invocationOwner?.sessionKey === this.slashCommandSessionKey(state.storageKey, sessionId)) {
|
|
2587
|
+
throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
|
|
1587
2588
|
}
|
|
2589
|
+
return this.trackSlashCommandExecution(
|
|
2590
|
+
state,
|
|
2591
|
+
sessionId,
|
|
2592
|
+
'operation',
|
|
2593
|
+
signal,
|
|
2594
|
+
async (operationSignal) => {
|
|
2595
|
+
await stored.projectionQueue;
|
|
2596
|
+
const resumable = stored.pendingReversion?.kind === 'apply'
|
|
2597
|
+
&& stored.pendingReversion.direction === direction;
|
|
2598
|
+
await this.requireReversionIdle(
|
|
2599
|
+
state,
|
|
2600
|
+
stored,
|
|
2601
|
+
sessionId,
|
|
2602
|
+
resumable,
|
|
2603
|
+
operationSignal,
|
|
2604
|
+
);
|
|
2605
|
+
await this.reconcileContextAvailability(state, stored);
|
|
2606
|
+
operationSignal.throwIfAborted();
|
|
2607
|
+
const pending = stored.pendingReversion;
|
|
2608
|
+
if (pending?.kind === 'apply') {
|
|
2609
|
+
if (pending.direction !== direction) {
|
|
2610
|
+
throw new FlexHarnessSessionBusyError(sessionId, 'has a pending opposite reversion operation');
|
|
2611
|
+
}
|
|
2612
|
+
const candidates = this.completedReversionCandidates(stored);
|
|
2613
|
+
const target = candidates[direction === 'undo' ? pending.fromCursor - 1 : pending.fromCursor];
|
|
2614
|
+
if (!target) throw new FlexHarnessValidationError('Pending reversion has no target candidate.');
|
|
2615
|
+
await this.resumePendingApply(state, stored, scopeId, scope, operationSignal);
|
|
2616
|
+
this.emitEvent(scopeId, sessionId, {
|
|
2617
|
+
type: 'session.history.changed',
|
|
2618
|
+
direction,
|
|
2619
|
+
runId: target.runId,
|
|
2620
|
+
});
|
|
2621
|
+
return target.runId;
|
|
2622
|
+
}
|
|
2623
|
+
const unit = this.reversionUnit(stored, direction);
|
|
2624
|
+
if (!unit) {
|
|
2625
|
+
throw new FlexHarnessSlashCommandUnavailableError(
|
|
2626
|
+
direction,
|
|
2627
|
+
direction === 'undo' ? 'No visible turn can be undone.' : 'No hidden turn can be redone.',
|
|
2628
|
+
);
|
|
2629
|
+
}
|
|
2630
|
+
if (!unit.target.contextAvailable) {
|
|
2631
|
+
throw new FlexHarnessSlashCommandUnavailableError(
|
|
2632
|
+
direction,
|
|
2633
|
+
'The turn is beyond the context archive horizon.',
|
|
2634
|
+
);
|
|
2635
|
+
}
|
|
2636
|
+
if (this.turnReversionProvider && unit.segments.some((segment) =>
|
|
2637
|
+
segment.captureId !== undefined && segment.workspaceReference === undefined)) {
|
|
2638
|
+
throw new FlexHarnessSlashCommandUnavailableError(
|
|
2639
|
+
direction,
|
|
2640
|
+
'The turn has no available workspace reversion capture.',
|
|
2641
|
+
);
|
|
2642
|
+
}
|
|
2643
|
+
const operationId = this.reversionId(
|
|
2644
|
+
'apply',
|
|
2645
|
+
state.storageKey,
|
|
2646
|
+
sessionId,
|
|
2647
|
+
JSON.stringify([direction, stored.revertCursor, unit.toCursor, unit.target.runId]),
|
|
2648
|
+
);
|
|
2649
|
+
await this.mutateProjection(state, stored, () => {
|
|
2650
|
+
stored.pendingReversion = {
|
|
2651
|
+
kind: 'apply',
|
|
2652
|
+
operationId,
|
|
2653
|
+
direction,
|
|
2654
|
+
fromCursor: stored.revertCursor,
|
|
2655
|
+
toCursor: unit.toCursor,
|
|
2656
|
+
segmentRunIds: unit.segments.map((segment) => segment.runId),
|
|
2657
|
+
appliedRunIds: [],
|
|
2658
|
+
};
|
|
2659
|
+
}, true);
|
|
2660
|
+
await this.resumePendingApply(
|
|
2661
|
+
state,
|
|
2662
|
+
stored,
|
|
2663
|
+
scopeId,
|
|
2664
|
+
scope,
|
|
2665
|
+
operationSignal,
|
|
2666
|
+
);
|
|
2667
|
+
this.emitEvent(scopeId, sessionId, {
|
|
2668
|
+
type: 'session.history.changed',
|
|
2669
|
+
direction,
|
|
2670
|
+
runId: unit.target.runId,
|
|
2671
|
+
});
|
|
2672
|
+
return unit.target.runId;
|
|
2673
|
+
},
|
|
2674
|
+
);
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
private async reconcileContextAvailability(
|
|
2678
|
+
state: IStorageState,
|
|
2679
|
+
stored: IStoredSessionState,
|
|
2680
|
+
): Promise<boolean> {
|
|
2681
|
+
const activeEvents = new Set(stored.agentSession.getEvents().map((event) => event.id));
|
|
2682
|
+
if (!stored.reversionSegments.some((segment) =>
|
|
2683
|
+
segment.contextAvailable
|
|
2684
|
+
&& segment.eventIds.length > 0
|
|
2685
|
+
&& !segment.eventIds.some((eventId) => activeEvents.has(eventId)))) return false;
|
|
2686
|
+
let branchCommitted = false;
|
|
2687
|
+
await this.mutateProjection(state, stored, () => {
|
|
2688
|
+
for (const segment of stored.reversionSegments) {
|
|
2689
|
+
if (
|
|
2690
|
+
segment.contextAvailable
|
|
2691
|
+
&& segment.eventIds.length > 0
|
|
2692
|
+
&& !segment.eventIds.some((eventId) => activeEvents.has(eventId))
|
|
2693
|
+
) segment.contextAvailable = false;
|
|
2694
|
+
}
|
|
2695
|
+
branchCommitted = this.commitArchivedHiddenBranchState(stored);
|
|
2696
|
+
this.pruneReversionState(stored, false, true);
|
|
2697
|
+
}, true);
|
|
2698
|
+
const context = stored.compactorContext as IFlexAgentContextInvocation<TScope> | undefined;
|
|
2699
|
+
if (context) {
|
|
2700
|
+
await this.drainReversionReleases(state, stored, context.scopeId, context.scope, false);
|
|
2701
|
+
}
|
|
2702
|
+
return branchCommitted;
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
private async resumePendingApply(
|
|
2706
|
+
state: IStorageState,
|
|
2707
|
+
stored: IStoredSessionState,
|
|
2708
|
+
scopeId: string,
|
|
2709
|
+
scope: TScope,
|
|
2710
|
+
signal?: AbortSignal,
|
|
2711
|
+
): Promise<void> {
|
|
2712
|
+
const pending = stored.pendingReversion;
|
|
2713
|
+
if (pending?.kind !== 'apply') return;
|
|
2714
|
+
const segmentMap = new Map(stored.reversionSegments.map((segment) => [segment.runId, segment]));
|
|
2715
|
+
if (
|
|
2716
|
+
pending.segmentRunIds.some((runId) => segmentMap.get(runId)?.workspaceCaptured)
|
|
2717
|
+
&& !this.turnReversionProvider
|
|
2718
|
+
) {
|
|
2719
|
+
throw new FlexHarnessValidationError(
|
|
2720
|
+
'Pending workspace reversion recovery requires its turn reversion provider.',
|
|
2721
|
+
);
|
|
2722
|
+
}
|
|
2723
|
+
const orderedRunIds = pending.direction === 'undo'
|
|
2724
|
+
? [...pending.segmentRunIds].reverse()
|
|
2725
|
+
: [...pending.segmentRunIds];
|
|
2726
|
+
for (const runId of orderedRunIds) {
|
|
2727
|
+
if (pending.appliedRunIds.includes(runId)) continue;
|
|
2728
|
+
if (signal?.aborted) {
|
|
2729
|
+
await this.persistKnownNotApplied(state, stored, pending.operationId, runId);
|
|
2730
|
+
throw signal.reason;
|
|
2731
|
+
}
|
|
2732
|
+
const segment = segmentMap.get(runId);
|
|
2733
|
+
if (!segment) throw new FlexHarnessValidationError('Pending reversion references a missing segment.');
|
|
2734
|
+
if (segment.workspaceCaptured) {
|
|
2735
|
+
if (!this.turnReversionProvider || !segment.captureId || segment.workspaceReference === undefined) {
|
|
2736
|
+
throw new FlexHarnessValidationError('Pending workspace reversion segment is incomplete.');
|
|
2737
|
+
}
|
|
2738
|
+
const captureId = segment.captureId;
|
|
2739
|
+
const reference = segment.workspaceReference;
|
|
2740
|
+
const childOperationId = `${pending.operationId}:${sha256Hex(runId).slice(0, 16)}`;
|
|
2741
|
+
const createContext = (contextSignal: AbortSignal) => Object.freeze({
|
|
2742
|
+
scopeId,
|
|
2743
|
+
scope,
|
|
2744
|
+
storageKey: state.storageKey,
|
|
2745
|
+
sessionId: stored.session.sessionId,
|
|
2746
|
+
runId,
|
|
2747
|
+
captureId,
|
|
2748
|
+
reference: cloneSerializable(reference),
|
|
2749
|
+
operationId: childOperationId,
|
|
2750
|
+
direction: pending.direction,
|
|
2751
|
+
signal: contextSignal,
|
|
2752
|
+
});
|
|
2753
|
+
let inspection;
|
|
2754
|
+
try {
|
|
2755
|
+
inspection = await this.withReversionMaintenanceSignal((inspectionSignal) =>
|
|
2756
|
+
this.turnReversionProvider!.inspectApply(createContext(inspectionSignal)));
|
|
2757
|
+
} catch (inspectionError) {
|
|
2758
|
+
state.lifecycle = 'fenced';
|
|
2759
|
+
throw this.projectOperationError(
|
|
2760
|
+
inspectionError,
|
|
2761
|
+
'turnReversion',
|
|
2762
|
+
scopeId,
|
|
2763
|
+
stored.session.sessionId,
|
|
2764
|
+
childOperationId,
|
|
2765
|
+
);
|
|
2766
|
+
}
|
|
2767
|
+
if (inspection.status === 'unknown') {
|
|
2768
|
+
state.lifecycle = 'fenced';
|
|
2769
|
+
throw new FlexHarnessValidationError('Workspace reversion apply outcome is unknown.');
|
|
2770
|
+
}
|
|
2771
|
+
if (inspection.status === 'not-applied') {
|
|
2772
|
+
try {
|
|
2773
|
+
if (signal) {
|
|
2774
|
+
await this.turnReversionProvider.apply(createContext(signal));
|
|
2775
|
+
} else {
|
|
2776
|
+
await this.withReversionMaintenanceSignal((maintenanceSignal) =>
|
|
2777
|
+
this.turnReversionProvider!.apply(createContext(maintenanceSignal)));
|
|
2778
|
+
}
|
|
2779
|
+
} catch (error) {
|
|
2780
|
+
try {
|
|
2781
|
+
inspection = await this.withReversionMaintenanceSignal((inspectionSignal) =>
|
|
2782
|
+
this.turnReversionProvider!.inspectApply(createContext(inspectionSignal)));
|
|
2783
|
+
} catch (inspectionError) {
|
|
2784
|
+
state.lifecycle = 'fenced';
|
|
2785
|
+
throw this.projectOperationError(
|
|
2786
|
+
inspectionError,
|
|
2787
|
+
'turnReversion',
|
|
2788
|
+
scopeId,
|
|
2789
|
+
stored.session.sessionId,
|
|
2790
|
+
childOperationId,
|
|
2791
|
+
);
|
|
2792
|
+
}
|
|
2793
|
+
if (inspection.status !== 'applied') {
|
|
2794
|
+
if (inspection.status === 'unknown') state.lifecycle = 'fenced';
|
|
2795
|
+
else await this.persistKnownNotApplied(state, stored, pending.operationId, runId);
|
|
2796
|
+
throw this.projectOperationError(
|
|
2797
|
+
error,
|
|
2798
|
+
'turnReversion',
|
|
2799
|
+
scopeId,
|
|
2800
|
+
stored.session.sessionId,
|
|
2801
|
+
childOperationId,
|
|
2802
|
+
);
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
await this.mutateProjection(state, stored, () => {
|
|
2808
|
+
const current = stored.pendingReversion;
|
|
2809
|
+
if (current?.kind !== 'apply' || current.operationId !== pending.operationId) {
|
|
2810
|
+
throw new FlexHarnessValidationError('Pending reversion changed while applying.');
|
|
2811
|
+
}
|
|
2812
|
+
if (!current.appliedRunIds.includes(runId)) current.appliedRunIds.push(runId);
|
|
2813
|
+
}, true);
|
|
2814
|
+
}
|
|
2815
|
+
await this.mutateProjection(state, stored, () => {
|
|
2816
|
+
const current = stored.pendingReversion;
|
|
2817
|
+
if (current?.kind !== 'apply' || current.operationId !== pending.operationId) {
|
|
2818
|
+
throw new FlexHarnessValidationError('Pending reversion changed before cursor commit.');
|
|
2819
|
+
}
|
|
2820
|
+
stored.revertCursor = current.toCursor;
|
|
2821
|
+
delete stored.pendingReversion;
|
|
2822
|
+
}, true);
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
private async persistKnownNotApplied(
|
|
2826
|
+
state: IStorageState,
|
|
2827
|
+
stored: IStoredSessionState,
|
|
2828
|
+
operationId: string,
|
|
2829
|
+
runId: string,
|
|
2830
|
+
): Promise<void> {
|
|
2831
|
+
await this.mutateProjection(state, stored, () => {
|
|
2832
|
+
const current = stored.pendingReversion;
|
|
2833
|
+
if (current?.kind !== 'apply' || current.operationId !== operationId) {
|
|
2834
|
+
throw new FlexHarnessValidationError('Pending reversion changed while recording failed apply.');
|
|
2835
|
+
}
|
|
2836
|
+
current.appliedRunIds = current.appliedRunIds.filter((entry) => entry !== runId);
|
|
2837
|
+
if (current.appliedRunIds.length === 0) {
|
|
2838
|
+
stored.revertCursor = current.fromCursor;
|
|
2839
|
+
delete stored.pendingReversion;
|
|
2840
|
+
}
|
|
2841
|
+
}, true);
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
private async recoverPendingCapture(
|
|
2845
|
+
state: IStorageState,
|
|
2846
|
+
stored: IStoredSessionState,
|
|
2847
|
+
scopeId: string,
|
|
2848
|
+
scope: TScope,
|
|
2849
|
+
outcomes: Map<string, TCanonicalOutcome>,
|
|
2850
|
+
): Promise<void> {
|
|
2851
|
+
const pending = stored.pendingReversion;
|
|
2852
|
+
if (pending?.kind !== 'capture' || !this.turnReversionProvider) return;
|
|
2853
|
+
const segment = stored.reversionSegments.find((entry) => entry.runId === pending.runId);
|
|
2854
|
+
if (!segment) throw new FlexHarnessValidationError('Pending capture references a missing segment.');
|
|
2855
|
+
const outcome = outcomes.get(pending.runId);
|
|
2856
|
+
const reference = await this.resolveReversionCaptureReference(
|
|
2857
|
+
state,
|
|
2858
|
+
scopeId,
|
|
2859
|
+
scope,
|
|
2860
|
+
stored.session.sessionId,
|
|
2861
|
+
pending.runId,
|
|
2862
|
+
pending.captureId,
|
|
2863
|
+
pending.state,
|
|
2864
|
+
);
|
|
2865
|
+
if (reference === undefined) {
|
|
2866
|
+
await this.mutateProjection(state, stored, () => {
|
|
2867
|
+
stored.reversionSegments = stored.reversionSegments.filter(
|
|
2868
|
+
(entry) => entry.runId !== pending.runId,
|
|
2869
|
+
);
|
|
2870
|
+
delete stored.pendingReversion;
|
|
2871
|
+
}, true);
|
|
2872
|
+
return;
|
|
2873
|
+
}
|
|
2874
|
+
await this.mutateProjection(state, stored, () => {
|
|
2875
|
+
segment.status = outcome === 'accepted'
|
|
2876
|
+
? 'completed'
|
|
2877
|
+
: outcome === 'rejected'
|
|
2878
|
+
? 'failed'
|
|
2879
|
+
: 'cancelled';
|
|
2880
|
+
segment.workspaceReference = cloneSerializable(reference);
|
|
2881
|
+
segment.eventIds = stored.agentSession.getEvents()
|
|
2882
|
+
.filter((event) => event.generationId === pending.runId)
|
|
2883
|
+
.map((event) => event.id);
|
|
2884
|
+
if (segment.status === 'completed') stored.revertCursor++;
|
|
2885
|
+
if (outcome === undefined && !stored.excludedRunIds.includes(pending.runId)) {
|
|
2886
|
+
stored.excludedRunIds.push(pending.runId);
|
|
2887
|
+
}
|
|
2888
|
+
delete stored.pendingReversion;
|
|
2889
|
+
this.pruneReversionState(stored, false, true);
|
|
2890
|
+
}, true);
|
|
2891
|
+
await this.drainReversionReleases(state, stored, scopeId, scope, false);
|
|
1588
2892
|
}
|
|
1589
2893
|
|
|
1590
2894
|
public async archiveSessionEvents(
|
|
@@ -1596,21 +2900,51 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1596
2900
|
validateIdentifier(compactionEventId, 'compactionEventId');
|
|
1597
2901
|
requireTransferIdentifier(compactionEventId, 'compactionEventId');
|
|
1598
2902
|
}
|
|
1599
|
-
const { state } = await this.resolveState(scopeId);
|
|
2903
|
+
const { scope, state } = await this.resolveState(scopeId);
|
|
1600
2904
|
const stored = this.requireSession(state, sessionId);
|
|
1601
2905
|
this.assertStateAcceptingWork(state);
|
|
2906
|
+
this.requireSessionAvailableForSlashCommand(state, stored, 'archive');
|
|
2907
|
+
const compaction = compactionEventId === undefined
|
|
2908
|
+
? [...stored.agentSession.getEvents()].reverse().find((event) =>
|
|
2909
|
+
event.type === 'context-compaction')
|
|
2910
|
+
: stored.agentSession.getEvents().find((event) =>
|
|
2911
|
+
event.type === 'context-compaction' && event.id === compactionEventId);
|
|
2912
|
+
if (!compaction || compaction.type !== 'context-compaction') return undefined;
|
|
2913
|
+
let archive;
|
|
1602
2914
|
try {
|
|
1603
|
-
|
|
1604
|
-
if (!archive) return undefined;
|
|
1605
|
-
return publicSnapshot({
|
|
1606
|
-
archiveId: this.boundedIdentifier(archive.archiveId, 'archiveId'),
|
|
1607
|
-
sessionId: this.boundedIdentifier(archive.sessionId, 'sessionId'),
|
|
1608
|
-
createdAt: new Date(archive.createdAt).toISOString(),
|
|
1609
|
-
eventCount: archive.events.length,
|
|
1610
|
-
});
|
|
2915
|
+
archive = await stored.agentSession.archiveCompactedEvents(compaction.id);
|
|
1611
2916
|
} catch (error) {
|
|
1612
2917
|
throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'archive');
|
|
1613
2918
|
}
|
|
2919
|
+
if (!archive) return undefined;
|
|
2920
|
+
let branchCommitted = false;
|
|
2921
|
+
try {
|
|
2922
|
+
const archivedIds = new Set(archive.events.map((event) => event.id));
|
|
2923
|
+
await this.mutateProjection(state, stored, () => {
|
|
2924
|
+
branchCommitted = this.commitRevertedBranchState(stored);
|
|
2925
|
+
for (const segment of stored.reversionSegments) {
|
|
2926
|
+
if (segment.eventIds.some((eventId) => archivedIds.has(eventId))) {
|
|
2927
|
+
segment.contextAvailable = false;
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
this.pruneReversionState(stored);
|
|
2931
|
+
}, true);
|
|
2932
|
+
} catch (error) {
|
|
2933
|
+
throw this.projectOperationError(error, 'persistence', scopeId, sessionId, 'archive-horizon');
|
|
2934
|
+
}
|
|
2935
|
+
if (branchCommitted) {
|
|
2936
|
+
this.emitEvent(scopeId, sessionId, {
|
|
2937
|
+
type: 'session.history.changed',
|
|
2938
|
+
direction: 'branch',
|
|
2939
|
+
});
|
|
2940
|
+
}
|
|
2941
|
+
await this.drainReversionReleases(state, stored, scopeId, scope.scope, false);
|
|
2942
|
+
return publicSnapshot({
|
|
2943
|
+
archiveId: this.boundedIdentifier(archive.archiveId, 'archiveId'),
|
|
2944
|
+
sessionId: this.boundedIdentifier(archive.sessionId, 'sessionId'),
|
|
2945
|
+
createdAt: new Date(archive.createdAt).toISOString(),
|
|
2946
|
+
eventCount: archive.events.length,
|
|
2947
|
+
});
|
|
1614
2948
|
}
|
|
1615
2949
|
|
|
1616
2950
|
public async listBackgroundExecutions(
|
|
@@ -1683,6 +3017,10 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1683
3017
|
}
|
|
1684
3018
|
|
|
1685
3019
|
public retireScope(scopeId: string): Promise<void> {
|
|
3020
|
+
const invocationOwner = this.slashCommandInvocationContext.getStore();
|
|
3021
|
+
if (invocationOwner?.scopeId === scopeId) {
|
|
3022
|
+
throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(invocationOwner.sessionId));
|
|
3023
|
+
}
|
|
1686
3024
|
this.assertOpen();
|
|
1687
3025
|
validateIdentifier(scopeId, 'scopeId');
|
|
1688
3026
|
const existing = this.scopeRetirements.get(scopeId);
|
|
@@ -1702,6 +3040,12 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1702
3040
|
|
|
1703
3041
|
public async dispose(): Promise<void> {
|
|
1704
3042
|
if (this.disposePromise) return this.disposePromise;
|
|
3043
|
+
const invocationOwner = this.slashCommandInvocationContext.getStore();
|
|
3044
|
+
if (invocationOwner) {
|
|
3045
|
+
throw this.trustInternalError(
|
|
3046
|
+
new FlexHarnessSlashCommandReentryError(invocationOwner.sessionId),
|
|
3047
|
+
);
|
|
3048
|
+
}
|
|
1705
3049
|
this.closed = true;
|
|
1706
3050
|
let disposal!: Promise<void>;
|
|
1707
3051
|
disposal = this.disposeInternal().catch((error) => {
|
|
@@ -1721,16 +3065,16 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1721
3065
|
debounceMs?: number,
|
|
1722
3066
|
subagentAdmission = false,
|
|
1723
3067
|
admissionSignal?: AbortSignal,
|
|
1724
|
-
|
|
3068
|
+
slashCommandAdmission = false,
|
|
3069
|
+
): Promise<{
|
|
3070
|
+
admission: IFlexPromptQueueAdmission;
|
|
3071
|
+
started: Promise<IFlexPromptAdmission>;
|
|
3072
|
+
queued?: IQueuedPrompt;
|
|
3073
|
+
}> {
|
|
1725
3074
|
this.assertOpen();
|
|
1726
3075
|
const normalizedPrompt = normalizeFlexPrompt(prompt);
|
|
1727
3076
|
const normalizedOptions = cloneSerializable(options);
|
|
1728
|
-
const byteSize =
|
|
1729
|
-
prompt: normalizedPrompt,
|
|
1730
|
-
options: normalizedOptions,
|
|
1731
|
-
scheduleKey,
|
|
1732
|
-
debounceMs,
|
|
1733
|
-
});
|
|
3077
|
+
const byteSize = this.promptAdmissionByteSize(prompt, options, scheduleKey, debounceMs);
|
|
1734
3078
|
const releasePendingAdmission = this.reservePendingPromptAdmission(byteSize);
|
|
1735
3079
|
let resolveSettled!: () => void;
|
|
1736
3080
|
const pendingOwner: IPendingPromptAdmission = {
|
|
@@ -1765,6 +3109,20 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1765
3109
|
}
|
|
1766
3110
|
this.assertStateAcceptingWork(state);
|
|
1767
3111
|
const stored = this.requireSession(state, sessionId);
|
|
3112
|
+
const sessionKey = this.slashCommandSessionKey(state.storageKey, sessionId);
|
|
3113
|
+
const invocationOwner = this.slashCommandInvocationContext.getStore();
|
|
3114
|
+
if (invocationOwner?.sessionKey === sessionKey) {
|
|
3115
|
+
throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(sessionId));
|
|
3116
|
+
}
|
|
3117
|
+
if (!slashCommandAdmission && this.activeSlashCommandExecutions.has(sessionKey)) {
|
|
3118
|
+
throw new FlexHarnessSessionBusyError(
|
|
3119
|
+
sessionId,
|
|
3120
|
+
'already has an active slash command execution',
|
|
3121
|
+
);
|
|
3122
|
+
}
|
|
3123
|
+
if (stored.pendingReversion) {
|
|
3124
|
+
throw new FlexHarnessSessionBusyError(sessionId, 'has a pending reversion operation');
|
|
3125
|
+
}
|
|
1768
3126
|
if (stored.session.agent !== undefined && !subagentAdmission) {
|
|
1769
3127
|
throw new FlexHarnessValidationError(
|
|
1770
3128
|
'Subagent sessions can only be prompted through the foreground task tool.',
|
|
@@ -1833,6 +3191,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1833
3191
|
return {
|
|
1834
3192
|
admission: Object.freeze({ queueId: queued.queueId, completion }),
|
|
1835
3193
|
started,
|
|
3194
|
+
queued,
|
|
1836
3195
|
};
|
|
1837
3196
|
} finally {
|
|
1838
3197
|
for (const signal of admissionSignals) {
|
|
@@ -1961,6 +3320,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1961
3320
|
const options = queued.options!;
|
|
1962
3321
|
let projectionReserved = false;
|
|
1963
3322
|
try {
|
|
3323
|
+
await this.commitRevertedBranch(run.state, run.stored, run.scopeId, run.scope as TScope);
|
|
1964
3324
|
run.transaction = await this.withRunCompactorContext(
|
|
1965
3325
|
run,
|
|
1966
3326
|
() => run.stored.agentSession.beginGeneration(
|
|
@@ -1974,6 +3334,29 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1974
3334
|
const reservation = this.createReservation(run, prompt);
|
|
1975
3335
|
await this.mutateProjection(run.state, run.stored, () => {
|
|
1976
3336
|
run.stored.messages.push(reservation.userMessage, reservation.assistantMessage);
|
|
3337
|
+
if (run.stored.session.agent === undefined) {
|
|
3338
|
+
this.pruneReversionState(run.stored, true);
|
|
3339
|
+
const captureId = this.turnReversionProvider
|
|
3340
|
+
? this.reversionId('capture', run.state.storageKey, run.sessionId, run.runId)
|
|
3341
|
+
: undefined;
|
|
3342
|
+
run.stored.reversionSegments.push({
|
|
3343
|
+
runId: run.runId,
|
|
3344
|
+
userMessageId: reservation.userMessage.messageId,
|
|
3345
|
+
status: 'capturing',
|
|
3346
|
+
contextAvailable: true,
|
|
3347
|
+
eventIds: [],
|
|
3348
|
+
workspaceCaptured: captureId !== undefined,
|
|
3349
|
+
...(captureId === undefined ? {} : { captureId }),
|
|
3350
|
+
});
|
|
3351
|
+
if (captureId) {
|
|
3352
|
+
run.stored.pendingReversion = {
|
|
3353
|
+
kind: 'capture',
|
|
3354
|
+
runId: run.runId,
|
|
3355
|
+
captureId,
|
|
3356
|
+
state: 'preparing',
|
|
3357
|
+
};
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
1977
3360
|
});
|
|
1978
3361
|
projectionReserved = true;
|
|
1979
3362
|
run.reservedUserMessage = publicSnapshot(reservation.userMessage);
|
|
@@ -2194,6 +3577,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2194
3577
|
if (run.controller.signal.aborted) throw run.controller.signal.reason;
|
|
2195
3578
|
const result = normalizeRunResult(rawResult);
|
|
2196
3579
|
if (!run.modelResolution) throw new FlexHarnessValidationError('Generation completed without a resolved model.');
|
|
3580
|
+
await this.finalizeRunReversion(run, 'completed');
|
|
2197
3581
|
const terminal = this.buildTerminal(run, 'completed', result);
|
|
2198
3582
|
try {
|
|
2199
3583
|
await this.mutateProjection(run.state, run.stored, () => {
|
|
@@ -2224,6 +3608,13 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2224
3608
|
}
|
|
2225
3609
|
this.emitTerminalProjection(run, terminal);
|
|
2226
3610
|
this.emitFinalRunEvent(run, terminal.assistantMessage, undefined, false);
|
|
3611
|
+
await this.drainReversionReleases(
|
|
3612
|
+
run.state,
|
|
3613
|
+
run.stored,
|
|
3614
|
+
run.scopeId,
|
|
3615
|
+
run.scope as TScope,
|
|
3616
|
+
false,
|
|
3617
|
+
);
|
|
2227
3618
|
return {
|
|
2228
3619
|
runId: run.runId,
|
|
2229
3620
|
sessionId: run.sessionId,
|
|
@@ -2264,6 +3655,14 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2264
3655
|
? run.ownerCancellation!
|
|
2265
3656
|
: this.projectExternalError(run, error, generated ? 'persistence' : 'agentSession');
|
|
2266
3657
|
const errors: unknown[] = [safeError];
|
|
3658
|
+
try {
|
|
3659
|
+
await this.finalizeRunReversion(run, cancelled ? 'cancelled' : 'failed');
|
|
3660
|
+
} catch (reversionError) {
|
|
3661
|
+
errors.push(reversionError);
|
|
3662
|
+
const combined = combineErrors(errors);
|
|
3663
|
+
await this.fenceNamespace(run.state, run, combined);
|
|
3664
|
+
throw combined;
|
|
3665
|
+
}
|
|
2267
3666
|
if (generated && !this.isTombstoned(run)) {
|
|
2268
3667
|
const failedStage = this.buildTerminal(run, cancelled ? 'cancelled' : 'failed', undefined, safeError);
|
|
2269
3668
|
try {
|
|
@@ -2318,6 +3717,13 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2318
3717
|
this.emitTerminalProjection(run, terminal);
|
|
2319
3718
|
this.emitFinalRunEvent(run, terminal.assistantMessage, terminalError, publicStatus === 'cancelled');
|
|
2320
3719
|
}
|
|
3720
|
+
await this.drainReversionReleases(
|
|
3721
|
+
run.state,
|
|
3722
|
+
run.stored,
|
|
3723
|
+
run.scopeId,
|
|
3724
|
+
run.scope as TScope,
|
|
3725
|
+
false,
|
|
3726
|
+
);
|
|
2321
3727
|
throw errors.length > 1 ? combineErrors(errors) : terminalError;
|
|
2322
3728
|
}
|
|
2323
3729
|
|
|
@@ -2327,6 +3733,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2327
3733
|
signal: AbortSignal,
|
|
2328
3734
|
): Promise<plugins.IAgentGenerationLease> {
|
|
2329
3735
|
signal.throwIfAborted();
|
|
3736
|
+
await this.prepareRunReversion(run, signal);
|
|
2330
3737
|
run.phase = 'running';
|
|
2331
3738
|
const queued = run.stored.outstandingPromptsById.get(run.queueId);
|
|
2332
3739
|
if (queued && (queued.status === 'starting' || queued.status === 'scheduled')) {
|
|
@@ -2353,16 +3760,18 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2353
3760
|
(value): IResolverOutcome<IFlexResolvedModel> => ({ source: 'model', success: true, value }),
|
|
2354
3761
|
(error): IResolverOutcome<IFlexResolvedModel> => ({ source: 'model', success: false, error }),
|
|
2355
3762
|
);
|
|
3763
|
+
const toolProviderContext: Readonly<IFlexToolProviderContext<TScope>> = Object.freeze({
|
|
3764
|
+
scopeId: run.scopeId,
|
|
3765
|
+
scope: run.scope as TScope,
|
|
3766
|
+
sessionId: run.sessionId,
|
|
3767
|
+
runId: run.runId,
|
|
3768
|
+
...resolverRelationship,
|
|
3769
|
+
signal,
|
|
3770
|
+
requestPermission: (request: IFlexPermissionRequestInput) =>
|
|
3771
|
+
this.requestPermission(run.state, run, request),
|
|
3772
|
+
});
|
|
2356
3773
|
const toolOutcome = Promise.resolve()
|
|
2357
|
-
.then(() => this.
|
|
2358
|
-
scopeId: run.scopeId,
|
|
2359
|
-
scope: run.scope as TScope,
|
|
2360
|
-
sessionId: run.sessionId,
|
|
2361
|
-
runId: run.runId,
|
|
2362
|
-
...resolverRelationship,
|
|
2363
|
-
signal,
|
|
2364
|
-
requestPermission: (request) => this.requestPermission(run.state, run, request),
|
|
2365
|
-
})))
|
|
3774
|
+
.then(() => this.provideRunToolHandle(run, toolProviderContext))
|
|
2366
3775
|
.then(
|
|
2367
3776
|
(value): IResolverOutcome<IFlexToolHandle | undefined> => ({ source: 'tools', success: true, value }),
|
|
2368
3777
|
(error): IResolverOutcome<IFlexToolHandle | undefined> => ({ source: 'tools', success: false, error }),
|
|
@@ -2992,39 +4401,781 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2992
4401
|
: 'Tool execution ended without a terminal event.';
|
|
2993
4402
|
}
|
|
2994
4403
|
}
|
|
2995
|
-
userMessage.status = status;
|
|
2996
|
-
userMessage.completedAt = timestamp;
|
|
2997
|
-
assistantMessage.status = status;
|
|
2998
|
-
assistantMessage.completedAt = timestamp;
|
|
2999
|
-
if (run.modelResolution) assistantMessage.model = cloneSerializable(run.modelResolution.identity);
|
|
3000
|
-
if (status === 'completed' && result) {
|
|
3001
|
-
if (!assistantMessage.parts.some((part) => part.type === 'text')) {
|
|
3002
|
-
assistantMessage.parts.push({
|
|
3003
|
-
partId: plugins.crypto.randomUUID(),
|
|
3004
|
-
type: 'text',
|
|
3005
|
-
text: truncateUtf8(result.text, this.callbackLimits.maxOutputBytes),
|
|
4404
|
+
userMessage.status = status;
|
|
4405
|
+
userMessage.completedAt = timestamp;
|
|
4406
|
+
assistantMessage.status = status;
|
|
4407
|
+
assistantMessage.completedAt = timestamp;
|
|
4408
|
+
if (run.modelResolution) assistantMessage.model = cloneSerializable(run.modelResolution.identity);
|
|
4409
|
+
if (status === 'completed' && result) {
|
|
4410
|
+
if (!assistantMessage.parts.some((part) => part.type === 'text')) {
|
|
4411
|
+
assistantMessage.parts.push({
|
|
4412
|
+
partId: plugins.crypto.randomUUID(),
|
|
4413
|
+
type: 'text',
|
|
4414
|
+
text: truncateUtf8(result.text, this.callbackLimits.maxOutputBytes),
|
|
4415
|
+
});
|
|
4416
|
+
}
|
|
4417
|
+
assistantMessage.usage = cloneSerializable(result.usage);
|
|
4418
|
+
} else {
|
|
4419
|
+
const message = truncateUtf8(error?.message ?? externalErrorFallback.message, maxTransferMetadataBytes);
|
|
4420
|
+
userMessage.error = message;
|
|
4421
|
+
assistantMessage.error = message;
|
|
4422
|
+
}
|
|
4423
|
+
return {
|
|
4424
|
+
runId: run.runId,
|
|
4425
|
+
status,
|
|
4426
|
+
userMessage,
|
|
4427
|
+
assistantMessage,
|
|
4428
|
+
...(run.modelResolution ? { model: cloneSerializable(run.modelResolution.identity) } : {}),
|
|
4429
|
+
...(status === 'completed' && result
|
|
4430
|
+
? {
|
|
4431
|
+
usage: cloneSerializable(result.usage),
|
|
4432
|
+
finishReason: result.finishReason,
|
|
4433
|
+
steps: result.steps,
|
|
4434
|
+
}
|
|
4435
|
+
: {}),
|
|
4436
|
+
};
|
|
4437
|
+
}
|
|
4438
|
+
|
|
4439
|
+
private reversionId(
|
|
4440
|
+
kind: 'capture' | 'apply',
|
|
4441
|
+
storageKey: string,
|
|
4442
|
+
sessionId: string,
|
|
4443
|
+
value: string,
|
|
4444
|
+
): string {
|
|
4445
|
+
return `${kind}_${sha256Hex(JSON.stringify([storageKey, sessionId, value]))}`;
|
|
4446
|
+
}
|
|
4447
|
+
|
|
4448
|
+
private reversionCaptureContext(
|
|
4449
|
+
run: IActiveRun,
|
|
4450
|
+
captureId: string,
|
|
4451
|
+
signal: AbortSignal,
|
|
4452
|
+
) {
|
|
4453
|
+
return Object.freeze({
|
|
4454
|
+
scopeId: run.scopeId,
|
|
4455
|
+
scope: run.scope as TScope,
|
|
4456
|
+
storageKey: run.state.storageKey,
|
|
4457
|
+
sessionId: run.sessionId,
|
|
4458
|
+
runId: run.runId,
|
|
4459
|
+
captureId,
|
|
4460
|
+
signal,
|
|
4461
|
+
});
|
|
4462
|
+
}
|
|
4463
|
+
|
|
4464
|
+
private async withReversionMaintenanceSignal<TResult>(
|
|
4465
|
+
operation: (signal: AbortSignal) => Promise<TResult> | TResult,
|
|
4466
|
+
): Promise<TResult> {
|
|
4467
|
+
const controller = new AbortController();
|
|
4468
|
+
const timeoutMs = this.agentSessionPolicy.generationLeaseCleanupTimeoutMs ?? 30_000;
|
|
4469
|
+
const timeoutError = new Error(`Turn reversion maintenance timed out after ${timeoutMs}ms.`);
|
|
4470
|
+
let rejectTimeout!: (error: Error) => void;
|
|
4471
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
4472
|
+
rejectTimeout = reject;
|
|
4473
|
+
});
|
|
4474
|
+
const timer = setTimeout(() => {
|
|
4475
|
+
controller.abort(timeoutError);
|
|
4476
|
+
rejectTimeout(timeoutError);
|
|
4477
|
+
}, timeoutMs);
|
|
4478
|
+
const completion = Promise.resolve().then(() => operation(controller.signal));
|
|
4479
|
+
void completion.catch(() => undefined);
|
|
4480
|
+
try {
|
|
4481
|
+
return await Promise.race([completion, timeout]);
|
|
4482
|
+
} finally {
|
|
4483
|
+
clearTimeout(timer);
|
|
4484
|
+
}
|
|
4485
|
+
}
|
|
4486
|
+
|
|
4487
|
+
private inspectReversionCapture(
|
|
4488
|
+
scopeId: string,
|
|
4489
|
+
scope: TScope,
|
|
4490
|
+
storageKey: string,
|
|
4491
|
+
sessionId: string,
|
|
4492
|
+
runId: string,
|
|
4493
|
+
captureId: string,
|
|
4494
|
+
) {
|
|
4495
|
+
return this.withReversionMaintenanceSignal((signal) =>
|
|
4496
|
+
this.turnReversionProvider!.inspectCapture(Object.freeze({
|
|
4497
|
+
scopeId,
|
|
4498
|
+
scope,
|
|
4499
|
+
storageKey,
|
|
4500
|
+
sessionId,
|
|
4501
|
+
runId,
|
|
4502
|
+
captureId,
|
|
4503
|
+
signal,
|
|
4504
|
+
})));
|
|
4505
|
+
}
|
|
4506
|
+
|
|
4507
|
+
private finalizeReversionCapture(
|
|
4508
|
+
scopeId: string,
|
|
4509
|
+
scope: TScope,
|
|
4510
|
+
storageKey: string,
|
|
4511
|
+
sessionId: string,
|
|
4512
|
+
runId: string,
|
|
4513
|
+
captureId: string,
|
|
4514
|
+
) {
|
|
4515
|
+
return this.withReversionMaintenanceSignal((signal) =>
|
|
4516
|
+
this.turnReversionProvider!.finalize(Object.freeze({
|
|
4517
|
+
scopeId,
|
|
4518
|
+
scope,
|
|
4519
|
+
storageKey,
|
|
4520
|
+
sessionId,
|
|
4521
|
+
runId,
|
|
4522
|
+
captureId,
|
|
4523
|
+
signal,
|
|
4524
|
+
})));
|
|
4525
|
+
}
|
|
4526
|
+
|
|
4527
|
+
private async resolveReversionCaptureReference(
|
|
4528
|
+
state: IStorageState,
|
|
4529
|
+
scopeId: string,
|
|
4530
|
+
scope: TScope,
|
|
4531
|
+
sessionId: string,
|
|
4532
|
+
runId: string,
|
|
4533
|
+
captureId: string,
|
|
4534
|
+
durableState: 'preparing' | 'prepared' | 'finalizing',
|
|
4535
|
+
): Promise<TJsonValue | undefined> {
|
|
4536
|
+
let inspection;
|
|
4537
|
+
try {
|
|
4538
|
+
inspection = await this.inspectReversionCapture(
|
|
4539
|
+
scopeId,
|
|
4540
|
+
scope,
|
|
4541
|
+
state.storageKey,
|
|
4542
|
+
sessionId,
|
|
4543
|
+
runId,
|
|
4544
|
+
captureId,
|
|
4545
|
+
);
|
|
4546
|
+
} catch (error) {
|
|
4547
|
+
state.lifecycle = 'fenced';
|
|
4548
|
+
throw this.projectOperationError(error, 'turnReversion', scopeId, sessionId, captureId);
|
|
4549
|
+
}
|
|
4550
|
+
if (inspection.status === 'unknown') {
|
|
4551
|
+
state.lifecycle = 'fenced';
|
|
4552
|
+
throw new FlexHarnessValidationError('Reversion capture outcome is unknown.');
|
|
4553
|
+
}
|
|
4554
|
+
if (inspection.status === 'missing') {
|
|
4555
|
+
if (durableState === 'preparing') return undefined;
|
|
4556
|
+
state.lifecycle = 'fenced';
|
|
4557
|
+
throw new FlexHarnessValidationError('Prepared reversion capture is missing.');
|
|
4558
|
+
}
|
|
4559
|
+
let reference = inspection.reference;
|
|
4560
|
+
if (inspection.status === 'prepared') {
|
|
4561
|
+
try {
|
|
4562
|
+
reference = await this.finalizeReversionCapture(
|
|
4563
|
+
scopeId,
|
|
4564
|
+
scope,
|
|
4565
|
+
state.storageKey,
|
|
4566
|
+
sessionId,
|
|
4567
|
+
runId,
|
|
4568
|
+
captureId,
|
|
4569
|
+
);
|
|
4570
|
+
} catch (error) {
|
|
4571
|
+
const finalInspection = await this.inspectReversionCapture(
|
|
4572
|
+
scopeId,
|
|
4573
|
+
scope,
|
|
4574
|
+
state.storageKey,
|
|
4575
|
+
sessionId,
|
|
4576
|
+
runId,
|
|
4577
|
+
captureId,
|
|
4578
|
+
);
|
|
4579
|
+
if (finalInspection.status !== 'finalized' || finalInspection.reference === undefined) {
|
|
4580
|
+
if (finalInspection.status === 'unknown') state.lifecycle = 'fenced';
|
|
4581
|
+
throw this.projectOperationError(error, 'turnReversion', scopeId, sessionId, captureId);
|
|
4582
|
+
}
|
|
4583
|
+
reference = finalInspection.reference;
|
|
4584
|
+
}
|
|
4585
|
+
}
|
|
4586
|
+
if (reference === undefined) {
|
|
4587
|
+
state.lifecycle = 'fenced';
|
|
4588
|
+
throw new FlexHarnessValidationError('Finalized reversion capture has no reference.');
|
|
4589
|
+
}
|
|
4590
|
+
return this.normalizeReversionReference(reference);
|
|
4591
|
+
}
|
|
4592
|
+
|
|
4593
|
+
private normalizeReversionReference(reference: unknown): TJsonValue {
|
|
4594
|
+
return normalizeJsonValue(reference, {
|
|
4595
|
+
maxDepth: this.toolOutputLimits.maxDepth,
|
|
4596
|
+
maxBytes: Math.min(
|
|
4597
|
+
this.toolOutputLimits.maxBytes,
|
|
4598
|
+
FLEX_REVERSION_REFERENCE_MAX_BYTES,
|
|
4599
|
+
),
|
|
4600
|
+
});
|
|
4601
|
+
}
|
|
4602
|
+
|
|
4603
|
+
private async prepareRunReversion(run: IActiveRun, signal: AbortSignal): Promise<void> {
|
|
4604
|
+
if (run.stored.session.agent !== undefined || !this.turnReversionProvider) return;
|
|
4605
|
+
const segment = run.stored.reversionSegments.find((entry) => entry.runId === run.runId);
|
|
4606
|
+
const captureId = segment?.captureId;
|
|
4607
|
+
if (!segment || !captureId) {
|
|
4608
|
+
throw this.projectExternalError(
|
|
4609
|
+
run,
|
|
4610
|
+
new Error('Root generation is missing its durable reversion capture intent.'),
|
|
4611
|
+
'turnReversion',
|
|
4612
|
+
);
|
|
4613
|
+
}
|
|
4614
|
+
const context = this.reversionCaptureContext(run, captureId, signal);
|
|
4615
|
+
try {
|
|
4616
|
+
await this.turnReversionProvider.prepare(context);
|
|
4617
|
+
} catch (error) {
|
|
4618
|
+
let inspection;
|
|
4619
|
+
try {
|
|
4620
|
+
inspection = await this.inspectReversionCapture(
|
|
4621
|
+
run.scopeId,
|
|
4622
|
+
run.scope as TScope,
|
|
4623
|
+
run.state.storageKey,
|
|
4624
|
+
run.sessionId,
|
|
4625
|
+
run.runId,
|
|
4626
|
+
captureId,
|
|
4627
|
+
);
|
|
4628
|
+
} catch (inspectionError) {
|
|
4629
|
+
run.state.lifecycle = 'fenced';
|
|
4630
|
+
throw this.projectExternalError(run, inspectionError, 'turnReversion');
|
|
4631
|
+
}
|
|
4632
|
+
if (inspection.status !== 'prepared' && inspection.status !== 'finalized') {
|
|
4633
|
+
if (inspection.status === 'unknown') run.state.lifecycle = 'fenced';
|
|
4634
|
+
throw this.projectExternalError(run, error, 'turnReversion');
|
|
4635
|
+
}
|
|
4636
|
+
}
|
|
4637
|
+
await this.mutateProjection(run.state, run.stored, () => {
|
|
4638
|
+
const pending = run.stored.pendingReversion;
|
|
4639
|
+
if (pending?.kind !== 'capture' || pending.runId !== run.runId) {
|
|
4640
|
+
throw new FlexHarnessValidationError('Reversion capture intent changed during preparation.');
|
|
4641
|
+
}
|
|
4642
|
+
pending.state = 'prepared';
|
|
4643
|
+
}, true);
|
|
4644
|
+
}
|
|
4645
|
+
|
|
4646
|
+
private async finalizeRunReversion(
|
|
4647
|
+
run: IActiveRun,
|
|
4648
|
+
status: 'completed' | 'failed' | 'cancelled',
|
|
4649
|
+
): Promise<void> {
|
|
4650
|
+
if (run.stored.session.agent !== undefined) return;
|
|
4651
|
+
const segment = run.stored.reversionSegments.find((entry) => entry.runId === run.runId);
|
|
4652
|
+
if (!segment) return;
|
|
4653
|
+
const eventIds = run.stored.agentSession.getEvents()
|
|
4654
|
+
.filter((event) => event.generationId === run.runId)
|
|
4655
|
+
.map((event) => event.id);
|
|
4656
|
+
if (!this.turnReversionProvider || !segment.captureId) {
|
|
4657
|
+
await this.mutateProjection(run.state, run.stored, () => {
|
|
4658
|
+
const wasCompleted = segment.status === 'completed';
|
|
4659
|
+
segment.status = status;
|
|
4660
|
+
segment.eventIds = eventIds;
|
|
4661
|
+
if (!wasCompleted && status === 'completed') run.stored.revertCursor++;
|
|
4662
|
+
else if (wasCompleted && status !== 'completed') run.stored.revertCursor--;
|
|
4663
|
+
delete run.stored.pendingReversion;
|
|
4664
|
+
this.pruneReversionState(run.stored);
|
|
4665
|
+
}, true);
|
|
4666
|
+
return;
|
|
4667
|
+
}
|
|
4668
|
+
const captureId = segment.captureId;
|
|
4669
|
+
if (segment.workspaceReference !== undefined) {
|
|
4670
|
+
await this.mutateProjection(run.state, run.stored, () => {
|
|
4671
|
+
const wasCompleted = segment.status === 'completed';
|
|
4672
|
+
segment.status = status;
|
|
4673
|
+
segment.eventIds = eventIds;
|
|
4674
|
+
if (!wasCompleted && status === 'completed') run.stored.revertCursor++;
|
|
4675
|
+
else if (wasCompleted && status !== 'completed') run.stored.revertCursor--;
|
|
4676
|
+
delete run.stored.pendingReversion;
|
|
4677
|
+
}, true);
|
|
4678
|
+
return;
|
|
4679
|
+
}
|
|
4680
|
+
const pending = run.stored.pendingReversion;
|
|
4681
|
+
if (pending?.kind !== 'capture' || pending.runId !== run.runId) {
|
|
4682
|
+
throw new FlexHarnessValidationError('Reversion capture intent changed before finalization.');
|
|
4683
|
+
}
|
|
4684
|
+
const reference = await this.resolveReversionCaptureReference(
|
|
4685
|
+
run.state,
|
|
4686
|
+
run.scopeId,
|
|
4687
|
+
run.scope as TScope,
|
|
4688
|
+
run.sessionId,
|
|
4689
|
+
run.runId,
|
|
4690
|
+
captureId,
|
|
4691
|
+
pending.state,
|
|
4692
|
+
);
|
|
4693
|
+
if (reference === undefined) {
|
|
4694
|
+
await this.mutateProjection(run.state, run.stored, () => {
|
|
4695
|
+
run.stored.reversionSegments = run.stored.reversionSegments.filter(
|
|
4696
|
+
(entry) => entry.runId !== run.runId,
|
|
4697
|
+
);
|
|
4698
|
+
delete run.stored.pendingReversion;
|
|
4699
|
+
}, true);
|
|
4700
|
+
return;
|
|
4701
|
+
}
|
|
4702
|
+
await this.mutateProjection(run.state, run.stored, () => {
|
|
4703
|
+
const current = run.stored.pendingReversion;
|
|
4704
|
+
if (current?.kind !== 'capture' || current.runId !== run.runId) {
|
|
4705
|
+
throw new FlexHarnessValidationError('Reversion capture intent changed before finalization.');
|
|
4706
|
+
}
|
|
4707
|
+
const wasCompleted = segment.status === 'completed';
|
|
4708
|
+
segment.status = status;
|
|
4709
|
+
segment.eventIds = eventIds;
|
|
4710
|
+
segment.workspaceReference = reference;
|
|
4711
|
+
if (!wasCompleted && status === 'completed') run.stored.revertCursor++;
|
|
4712
|
+
else if (wasCompleted && status !== 'completed') run.stored.revertCursor--;
|
|
4713
|
+
delete run.stored.pendingReversion;
|
|
4714
|
+
this.pruneReversionState(run.stored);
|
|
4715
|
+
}, true);
|
|
4716
|
+
}
|
|
4717
|
+
|
|
4718
|
+
private hiddenReversionSegments(stored: IStoredSessionState): IFlexReversionSegment[] {
|
|
4719
|
+
const candidates = this.completedReversionCandidates(stored);
|
|
4720
|
+
const firstHidden = candidates[stored.revertCursor];
|
|
4721
|
+
if (!firstHidden) return [];
|
|
4722
|
+
const index = stored.reversionSegments.findIndex((segment) => segment.runId === firstHidden.runId);
|
|
4723
|
+
if (index < 0) return [];
|
|
4724
|
+
return stored.reversionSegments.slice(firstHidden === candidates[0] ? 0 : index);
|
|
4725
|
+
}
|
|
4726
|
+
|
|
4727
|
+
private enqueueReversionRelease(
|
|
4728
|
+
stored: IStoredSessionState,
|
|
4729
|
+
segment: IFlexReversionSegment,
|
|
4730
|
+
): void {
|
|
4731
|
+
if (!segment.workspaceCaptured) return;
|
|
4732
|
+
if (segment.captureId === undefined || segment.workspaceReference === undefined) {
|
|
4733
|
+
throw new FlexHarnessValidationError('Capture-backed segment has no releasable workspace reference.');
|
|
4734
|
+
}
|
|
4735
|
+
if (stored.pendingReversionReleases.some((release) => release.captureId === segment.captureId)) {
|
|
4736
|
+
return;
|
|
4737
|
+
}
|
|
4738
|
+
if (stored.pendingReversionReleases.length >= this.reversionLimits.maxPendingReversionReleases) {
|
|
4739
|
+
throw new FlexHarnessValidationError('Pending reversion release limit was reached.');
|
|
4740
|
+
}
|
|
4741
|
+
stored.pendingReversionReleases.push({
|
|
4742
|
+
runId: segment.runId,
|
|
4743
|
+
captureId: segment.captureId,
|
|
4744
|
+
reference: cloneSerializable(segment.workspaceReference),
|
|
4745
|
+
});
|
|
4746
|
+
}
|
|
4747
|
+
|
|
4748
|
+
private pruneReversionState(
|
|
4749
|
+
stored: IStoredSessionState,
|
|
4750
|
+
reserveSegment = false,
|
|
4751
|
+
dropNonUndoablePrefix = false,
|
|
4752
|
+
): void {
|
|
4753
|
+
if (stored.pendingReversion || stored.revertCursor !== this.completedReversionCandidates(stored).length) {
|
|
4754
|
+
return;
|
|
4755
|
+
}
|
|
4756
|
+
while (stored.reversionSegments.length > 0) {
|
|
4757
|
+
const candidates = this.completedReversionCandidates(stored);
|
|
4758
|
+
const overLimit = candidates.length + (reserveSegment ? 1 : 0)
|
|
4759
|
+
> this.reversionLimits.maxCompletedTurns
|
|
4760
|
+
|| stored.reversionSegments.length + (reserveSegment ? 1 : 0)
|
|
4761
|
+
> this.reversionLimits.maxSegments;
|
|
4762
|
+
const firstCandidate = candidates[0];
|
|
4763
|
+
const nextCandidate = candidates[1];
|
|
4764
|
+
let end = 0;
|
|
4765
|
+
if (!firstCandidate) {
|
|
4766
|
+
if (dropNonUndoablePrefix) end = stored.reversionSegments.length;
|
|
4767
|
+
else if (overLimit) {
|
|
4768
|
+
end = Math.max(
|
|
4769
|
+
1,
|
|
4770
|
+
stored.reversionSegments.length + (reserveSegment ? 1 : 0)
|
|
4771
|
+
- this.reversionLimits.maxSegments,
|
|
4772
|
+
);
|
|
4773
|
+
} else break;
|
|
4774
|
+
} else if (!firstCandidate.contextAvailable) {
|
|
4775
|
+
end = nextCandidate
|
|
4776
|
+
? stored.reversionSegments.findIndex((segment) => segment.runId === nextCandidate.runId)
|
|
4777
|
+
: stored.reversionSegments.length;
|
|
4778
|
+
} else if (overLimit) {
|
|
4779
|
+
end = nextCandidate
|
|
4780
|
+
? stored.reversionSegments.findIndex((segment) => segment.runId === nextCandidate.runId)
|
|
4781
|
+
: stored.reversionSegments.length;
|
|
4782
|
+
} else {
|
|
4783
|
+
break;
|
|
4784
|
+
}
|
|
4785
|
+
if (end <= 0) break;
|
|
4786
|
+
const prefix = stored.reversionSegments.slice(0, end);
|
|
4787
|
+
if (prefix.some((segment) => segment.workspaceCaptured && segment.workspaceReference === undefined)) break;
|
|
4788
|
+
for (const segment of prefix) this.enqueueReversionRelease(stored, segment);
|
|
4789
|
+
stored.reversionSegments.splice(0, end);
|
|
4790
|
+
stored.revertCursor = Math.max(
|
|
4791
|
+
0,
|
|
4792
|
+
stored.revertCursor - prefix.filter((segment) => segment.status === 'completed').length,
|
|
4793
|
+
);
|
|
4794
|
+
}
|
|
4795
|
+
if (stored.excludedRunIds.length > this.reversionLimits.maxExcludedRunIds) {
|
|
4796
|
+
throw new FlexHarnessValidationError('Excluded reversion run limit was reached.');
|
|
4797
|
+
}
|
|
4798
|
+
}
|
|
4799
|
+
|
|
4800
|
+
private async commitRevertedBranch(
|
|
4801
|
+
state: IStorageState,
|
|
4802
|
+
stored: IStoredSessionState,
|
|
4803
|
+
scopeId: string,
|
|
4804
|
+
scope: TScope,
|
|
4805
|
+
): Promise<void> {
|
|
4806
|
+
if (stored.pendingReversionReleases.length > 0) {
|
|
4807
|
+
await this.drainReversionReleases(state, stored, scopeId, scope, false);
|
|
4808
|
+
this.assertStateAcceptingWork(state);
|
|
4809
|
+
}
|
|
4810
|
+
if (this.hiddenReversionSegments(stored).length === 0) {
|
|
4811
|
+
return;
|
|
4812
|
+
}
|
|
4813
|
+
await this.mutateProjection(state, stored, () => this.commitRevertedBranchState(stored), true);
|
|
4814
|
+
this.emitEvent(scopeId, stored.session.sessionId, {
|
|
4815
|
+
type: 'session.history.changed',
|
|
4816
|
+
direction: 'branch',
|
|
4817
|
+
});
|
|
4818
|
+
await this.drainReversionReleases(state, stored, scopeId, scope, false);
|
|
4819
|
+
}
|
|
4820
|
+
|
|
4821
|
+
private commitRevertedBranchState(stored: IStoredSessionState): boolean {
|
|
4822
|
+
const hidden = this.hiddenReversionSegments(stored);
|
|
4823
|
+
if (hidden.length === 0) return false;
|
|
4824
|
+
const hiddenRunIds = new Set(hidden.map((segment) => segment.runId));
|
|
4825
|
+
stored.messages = stored.messages.filter((message) => !hiddenRunIds.has(message.runId));
|
|
4826
|
+
stored.stagedTerminals = stored.stagedTerminals.filter(
|
|
4827
|
+
(terminal) => !hiddenRunIds.has(terminal.runId),
|
|
4828
|
+
);
|
|
4829
|
+
for (const segment of hidden) {
|
|
4830
|
+
this.enqueueReversionRelease(stored, segment);
|
|
4831
|
+
if (!stored.excludedRunIds.includes(segment.runId)) stored.excludedRunIds.push(segment.runId);
|
|
4832
|
+
}
|
|
4833
|
+
stored.reversionSegments = stored.reversionSegments.filter(
|
|
4834
|
+
(segment) => !hiddenRunIds.has(segment.runId),
|
|
4835
|
+
);
|
|
4836
|
+
stored.revertCursor = this.completedReversionCandidates(stored).length;
|
|
4837
|
+
this.pruneReversionState(stored);
|
|
4838
|
+
return true;
|
|
4839
|
+
}
|
|
4840
|
+
|
|
4841
|
+
private commitArchivedHiddenBranchState(stored: IStoredSessionState): boolean {
|
|
4842
|
+
const hidden = this.hiddenReversionSegments(stored);
|
|
4843
|
+
return hidden.some((segment) => !segment.contextAvailable)
|
|
4844
|
+
? this.commitRevertedBranchState(stored)
|
|
4845
|
+
: false;
|
|
4846
|
+
}
|
|
4847
|
+
|
|
4848
|
+
private filterReversionContextEvents(
|
|
4849
|
+
stored: IStoredSessionState,
|
|
4850
|
+
events: readonly plugins.TAgentEvent[],
|
|
4851
|
+
): plugins.TAgentEvent[] {
|
|
4852
|
+
const hiddenRunIds = new Set([
|
|
4853
|
+
...this.hiddenReversionSegments(stored).map((segment) => segment.runId),
|
|
4854
|
+
...stored.excludedRunIds,
|
|
4855
|
+
]);
|
|
4856
|
+
const hiddenRawIds = new Set([
|
|
4857
|
+
...this.hiddenReversionSegments(stored).flatMap((segment) => segment.eventIds),
|
|
4858
|
+
...events.filter((event) => event.generationId && hiddenRunIds.has(event.generationId))
|
|
4859
|
+
.map((event) => event.id),
|
|
4860
|
+
]);
|
|
4861
|
+
const taintedCompactionIds = new Set<string>();
|
|
4862
|
+
let changed = true;
|
|
4863
|
+
while (changed) {
|
|
4864
|
+
changed = false;
|
|
4865
|
+
for (const event of events) {
|
|
4866
|
+
if (event.type !== 'context-compaction' || taintedCompactionIds.has(event.id)) continue;
|
|
4867
|
+
if (
|
|
4868
|
+
event.coveredEventIds.some((id) => hiddenRawIds.has(id) || taintedCompactionIds.has(id))
|
|
4869
|
+
|| event.archivedTransactions?.some((transaction) =>
|
|
4870
|
+
hiddenRunIds.has(transaction.generationId))
|
|
4871
|
+
) {
|
|
4872
|
+
taintedCompactionIds.add(event.id);
|
|
4873
|
+
changed = true;
|
|
4874
|
+
}
|
|
4875
|
+
}
|
|
4876
|
+
}
|
|
4877
|
+
return events.filter((event) =>
|
|
4878
|
+
!(event.generationId && hiddenRunIds.has(event.generationId))
|
|
4879
|
+
&& !taintedCompactionIds.has(event.id));
|
|
4880
|
+
}
|
|
4881
|
+
|
|
4882
|
+
private async drainReversionReleases(
|
|
4883
|
+
state: IStorageState,
|
|
4884
|
+
stored: IStoredSessionState,
|
|
4885
|
+
scopeId: string,
|
|
4886
|
+
scope: TScope,
|
|
4887
|
+
throwOnFailure: boolean,
|
|
4888
|
+
): Promise<void> {
|
|
4889
|
+
await this.reconcileProjectionFromStore(stored);
|
|
4890
|
+
if (!this.turnReversionProvider) {
|
|
4891
|
+
if (stored.pendingReversionReleases.length > 0) {
|
|
4892
|
+
throw new FlexHarnessValidationError(
|
|
4893
|
+
'Pending workspace capture releases require their turn reversion provider.',
|
|
4894
|
+
);
|
|
4895
|
+
}
|
|
4896
|
+
return;
|
|
4897
|
+
}
|
|
4898
|
+
while (stored.pendingReversionReleases.length > 0) {
|
|
4899
|
+
const release = stored.pendingReversionReleases[0];
|
|
4900
|
+
try {
|
|
4901
|
+
await this.withReversionMaintenanceSignal((signal) =>
|
|
4902
|
+
this.turnReversionProvider!.release(Object.freeze({
|
|
4903
|
+
scopeId,
|
|
4904
|
+
scope,
|
|
4905
|
+
storageKey: state.storageKey,
|
|
4906
|
+
sessionId: stored.session.sessionId,
|
|
4907
|
+
runId: release.runId,
|
|
4908
|
+
captureId: release.captureId,
|
|
4909
|
+
reference: cloneSerializable(release.reference),
|
|
4910
|
+
signal,
|
|
4911
|
+
})));
|
|
4912
|
+
} catch (error) {
|
|
4913
|
+
if (throwOnFailure) {
|
|
4914
|
+
throw this.projectOperationError(
|
|
4915
|
+
error,
|
|
4916
|
+
'turnReversion',
|
|
4917
|
+
scopeId,
|
|
4918
|
+
stored.session.sessionId,
|
|
4919
|
+
`release:${release.captureId}`,
|
|
4920
|
+
);
|
|
4921
|
+
}
|
|
4922
|
+
return;
|
|
4923
|
+
}
|
|
4924
|
+
try {
|
|
4925
|
+
await this.mutateProjection(state, stored, () => {
|
|
4926
|
+
if (stored.pendingReversionReleases[0]?.captureId === release.captureId) {
|
|
4927
|
+
stored.pendingReversionReleases.shift();
|
|
4928
|
+
}
|
|
4929
|
+
}, true);
|
|
4930
|
+
} catch (error) {
|
|
4931
|
+
if (!throwOnFailure && state.lifecycle === 'fenced') {
|
|
4932
|
+
this.deferReversionReleaseDrain(state, stored, scopeId, scope);
|
|
4933
|
+
}
|
|
4934
|
+
if (throwOnFailure) throw error;
|
|
4935
|
+
return;
|
|
4936
|
+
}
|
|
4937
|
+
if (stored.projectionReconciliationRequired) return;
|
|
4938
|
+
}
|
|
4939
|
+
}
|
|
4940
|
+
|
|
4941
|
+
private async releaseDeletedSessionReversions(
|
|
4942
|
+
state: IStorageState,
|
|
4943
|
+
storageKey: string,
|
|
4944
|
+
sessionId: string,
|
|
4945
|
+
scopeId: string,
|
|
4946
|
+
scope: TScope,
|
|
4947
|
+
stored?: IStoredSessionState,
|
|
4948
|
+
): Promise<void> {
|
|
4949
|
+
if (stored) await stored.projectionQueue;
|
|
4950
|
+
const loadedProjection = await this.stores.projections.load(storageKey, sessionId);
|
|
4951
|
+
if (loadedProjection?.schemaVersion !== 2) return;
|
|
4952
|
+
let projection: IFlexProjectionSnapshotCurrent = loadedProjection;
|
|
4953
|
+
if (projection.pendingReversion?.kind === 'apply') {
|
|
4954
|
+
if (stored) {
|
|
4955
|
+
await this.resumePendingApply(state, stored, scopeId, scope);
|
|
4956
|
+
} else {
|
|
4957
|
+
const pending = projection.pendingReversion;
|
|
4958
|
+
const orderedRunIds = pending.direction === 'undo'
|
|
4959
|
+
? [...pending.segmentRunIds].reverse()
|
|
4960
|
+
: [...pending.segmentRunIds];
|
|
4961
|
+
for (const runId of orderedRunIds) {
|
|
4962
|
+
if (pending.appliedRunIds.includes(runId)) continue;
|
|
4963
|
+
const segment = projection.reversionSegments.find((entry) => entry.runId === runId)!;
|
|
4964
|
+
if (segment.workspaceCaptured) {
|
|
4965
|
+
if (!this.turnReversionProvider || !segment.captureId || segment.workspaceReference === undefined) {
|
|
4966
|
+
throw new FlexHarnessValidationError(
|
|
4967
|
+
'Pending workspace reversion deletion requires its turn reversion provider.',
|
|
4968
|
+
);
|
|
4969
|
+
}
|
|
4970
|
+
const captureId = segment.captureId;
|
|
4971
|
+
const reference = segment.workspaceReference;
|
|
4972
|
+
const operationId = `${pending.operationId}:${sha256Hex(runId).slice(0, 16)}`;
|
|
4973
|
+
const createContext = (signal: AbortSignal) => Object.freeze({
|
|
4974
|
+
scopeId,
|
|
4975
|
+
scope,
|
|
4976
|
+
storageKey,
|
|
4977
|
+
sessionId,
|
|
4978
|
+
runId,
|
|
4979
|
+
captureId,
|
|
4980
|
+
reference: cloneSerializable(reference),
|
|
4981
|
+
operationId,
|
|
4982
|
+
direction: pending.direction,
|
|
4983
|
+
signal,
|
|
4984
|
+
});
|
|
4985
|
+
let inspection = await this.withReversionMaintenanceSignal((signal) =>
|
|
4986
|
+
this.turnReversionProvider!.inspectApply(createContext(signal)));
|
|
4987
|
+
if (inspection.status === 'unknown') {
|
|
4988
|
+
throw new FlexHarnessValidationError('Workspace reversion apply outcome is unknown.');
|
|
4989
|
+
}
|
|
4990
|
+
if (inspection.status === 'not-applied') {
|
|
4991
|
+
try {
|
|
4992
|
+
await this.withReversionMaintenanceSignal((signal) =>
|
|
4993
|
+
this.turnReversionProvider!.apply(createContext(signal)));
|
|
4994
|
+
} catch (error) {
|
|
4995
|
+
inspection = await this.withReversionMaintenanceSignal((signal) =>
|
|
4996
|
+
this.turnReversionProvider!.inspectApply(createContext(signal)));
|
|
4997
|
+
if (inspection.status !== 'applied') throw error;
|
|
4998
|
+
}
|
|
4999
|
+
}
|
|
5000
|
+
}
|
|
5001
|
+
pending.appliedRunIds.push(runId);
|
|
5002
|
+
const next = { ...projection, revision: projection.revision + 1 };
|
|
5003
|
+
await this.stores.projections.save(storageKey, sessionId, next, projection.revision);
|
|
5004
|
+
projection = next;
|
|
5005
|
+
}
|
|
5006
|
+
const { pendingReversion: _pendingReversion, ...completedProjection } = projection;
|
|
5007
|
+
const completed: IFlexProjectionSnapshotCurrent = {
|
|
5008
|
+
...completedProjection,
|
|
5009
|
+
revision: projection.revision + 1,
|
|
5010
|
+
revertCursor: pending.toCursor,
|
|
5011
|
+
};
|
|
5012
|
+
await this.stores.projections.save(storageKey, sessionId, completed, projection.revision);
|
|
5013
|
+
projection = completed;
|
|
5014
|
+
}
|
|
5015
|
+
const recoveredProjection = await this.stores.projections.load(storageKey, sessionId);
|
|
5016
|
+
if (recoveredProjection?.schemaVersion !== 2) return;
|
|
5017
|
+
projection = recoveredProjection;
|
|
5018
|
+
}
|
|
5019
|
+
const hasWorkspaceCaptures = projection.reversionSegments.some((segment) => segment.captureId)
|
|
5020
|
+
|| projection.pendingReversionReleases.length > 0
|
|
5021
|
+
|| projection.pendingReversion?.kind === 'capture';
|
|
5022
|
+
if (!this.turnReversionProvider) {
|
|
5023
|
+
if (hasWorkspaceCaptures) {
|
|
5024
|
+
throw new FlexHarnessValidationError(
|
|
5025
|
+
'Capture-backed session deletion requires its turn reversion provider.',
|
|
5026
|
+
);
|
|
5027
|
+
}
|
|
5028
|
+
return;
|
|
5029
|
+
}
|
|
5030
|
+
const releases = new Map<string, IFlexPendingReversionRelease>();
|
|
5031
|
+
for (const release of projection.pendingReversionReleases) {
|
|
5032
|
+
releases.set(release.captureId, cloneSerializable(release));
|
|
5033
|
+
}
|
|
5034
|
+
for (const segment of projection.reversionSegments) {
|
|
5035
|
+
if (segment.captureId && segment.workspaceReference !== undefined) {
|
|
5036
|
+
releases.set(segment.captureId, {
|
|
5037
|
+
runId: segment.runId,
|
|
5038
|
+
captureId: segment.captureId,
|
|
5039
|
+
reference: cloneSerializable(segment.workspaceReference),
|
|
3006
5040
|
});
|
|
3007
5041
|
}
|
|
3008
|
-
assistantMessage.usage = cloneSerializable(result.usage);
|
|
3009
|
-
} else {
|
|
3010
|
-
const message = truncateUtf8(error?.message ?? externalErrorFallback.message, maxTransferMetadataBytes);
|
|
3011
|
-
userMessage.error = message;
|
|
3012
|
-
assistantMessage.error = message;
|
|
3013
5042
|
}
|
|
3014
|
-
|
|
3015
|
-
runId:
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
5043
|
+
const unresolvedCaptures = new Map<string, {
|
|
5044
|
+
runId: string;
|
|
5045
|
+
captureId: string;
|
|
5046
|
+
durableState?: 'preparing' | 'prepared' | 'finalizing';
|
|
5047
|
+
}>();
|
|
5048
|
+
for (const segment of projection.reversionSegments) {
|
|
5049
|
+
if (segment.captureId && !releases.has(segment.captureId)) {
|
|
5050
|
+
unresolvedCaptures.set(segment.captureId, {
|
|
5051
|
+
runId: segment.runId,
|
|
5052
|
+
captureId: segment.captureId,
|
|
5053
|
+
});
|
|
5054
|
+
}
|
|
5055
|
+
}
|
|
5056
|
+
if (projection.pendingReversion?.kind === 'capture') {
|
|
5057
|
+
unresolvedCaptures.set(projection.pendingReversion.captureId, {
|
|
5058
|
+
runId: projection.pendingReversion.runId,
|
|
5059
|
+
captureId: projection.pendingReversion.captureId,
|
|
5060
|
+
durableState: projection.pendingReversion.state,
|
|
5061
|
+
});
|
|
5062
|
+
}
|
|
5063
|
+
for (const capture of unresolvedCaptures.values()) {
|
|
5064
|
+
let inspection;
|
|
5065
|
+
try {
|
|
5066
|
+
inspection = await this.inspectReversionCapture(
|
|
5067
|
+
scopeId,
|
|
5068
|
+
scope,
|
|
5069
|
+
storageKey,
|
|
5070
|
+
sessionId,
|
|
5071
|
+
capture.runId,
|
|
5072
|
+
capture.captureId,
|
|
5073
|
+
);
|
|
5074
|
+
} catch (error) {
|
|
5075
|
+
throw this.projectOperationError(
|
|
5076
|
+
error,
|
|
5077
|
+
'turnReversion',
|
|
5078
|
+
scopeId,
|
|
5079
|
+
sessionId,
|
|
5080
|
+
`release:${capture.captureId}`,
|
|
5081
|
+
);
|
|
5082
|
+
}
|
|
5083
|
+
if (inspection.status === 'unknown') {
|
|
5084
|
+
throw new FlexHarnessValidationError('Deleted session capture outcome is unknown.');
|
|
5085
|
+
}
|
|
5086
|
+
if (inspection.status === 'missing') {
|
|
5087
|
+
if (capture.durableState === 'preparing') continue;
|
|
5088
|
+
state.lifecycle = 'fenced';
|
|
5089
|
+
throw new FlexHarnessValidationError('Prepared deleted-session capture is missing.');
|
|
5090
|
+
}
|
|
5091
|
+
let reference = inspection.reference;
|
|
5092
|
+
if (inspection.status === 'prepared') {
|
|
5093
|
+
try {
|
|
5094
|
+
reference = await this.finalizeReversionCapture(
|
|
5095
|
+
scopeId,
|
|
5096
|
+
scope,
|
|
5097
|
+
storageKey,
|
|
5098
|
+
sessionId,
|
|
5099
|
+
capture.runId,
|
|
5100
|
+
capture.captureId,
|
|
5101
|
+
);
|
|
5102
|
+
} catch (error) {
|
|
5103
|
+
const finalInspection = await this.inspectReversionCapture(
|
|
5104
|
+
scopeId,
|
|
5105
|
+
scope,
|
|
5106
|
+
storageKey,
|
|
5107
|
+
sessionId,
|
|
5108
|
+
capture.runId,
|
|
5109
|
+
capture.captureId,
|
|
5110
|
+
);
|
|
5111
|
+
if (finalInspection.status !== 'finalized' || finalInspection.reference === undefined) {
|
|
5112
|
+
throw this.projectOperationError(
|
|
5113
|
+
error,
|
|
5114
|
+
'turnReversion',
|
|
5115
|
+
scopeId,
|
|
5116
|
+
sessionId,
|
|
5117
|
+
`release:${capture.captureId}`,
|
|
5118
|
+
);
|
|
3025
5119
|
}
|
|
3026
|
-
|
|
3027
|
-
|
|
5120
|
+
reference = finalInspection.reference;
|
|
5121
|
+
}
|
|
5122
|
+
}
|
|
5123
|
+
if (reference === undefined) {
|
|
5124
|
+
throw new FlexHarnessValidationError('Deleted session capture has no release reference.');
|
|
5125
|
+
}
|
|
5126
|
+
releases.set(capture.captureId, {
|
|
5127
|
+
runId: capture.runId,
|
|
5128
|
+
captureId: capture.captureId,
|
|
5129
|
+
reference: this.normalizeReversionReference(reference),
|
|
5130
|
+
});
|
|
5131
|
+
}
|
|
5132
|
+
if (stored) {
|
|
5133
|
+
await this.mutateProjection(state, stored, () => {
|
|
5134
|
+
for (const release of releases.values()) {
|
|
5135
|
+
if (!stored.pendingReversionReleases.some((entry) => entry.captureId === release.captureId)) {
|
|
5136
|
+
stored.pendingReversionReleases.push(cloneSerializable(release));
|
|
5137
|
+
}
|
|
5138
|
+
}
|
|
5139
|
+
}, true);
|
|
5140
|
+
} else {
|
|
5141
|
+
const missing = [...releases.values()].filter((release) =>
|
|
5142
|
+
!projection.pendingReversionReleases.some((entry) => entry.captureId === release.captureId));
|
|
5143
|
+
if (missing.length > 0) {
|
|
5144
|
+
const next: IFlexProjectionSnapshotCurrent = {
|
|
5145
|
+
...projection,
|
|
5146
|
+
revision: projection.revision + 1,
|
|
5147
|
+
pendingReversionReleases: [
|
|
5148
|
+
...projection.pendingReversionReleases,
|
|
5149
|
+
...missing.map((release) => cloneSerializable(release)),
|
|
5150
|
+
],
|
|
5151
|
+
};
|
|
5152
|
+
await this.stores.projections.save(storageKey, sessionId, next, projection.revision);
|
|
5153
|
+
projection = next;
|
|
5154
|
+
}
|
|
5155
|
+
}
|
|
5156
|
+
for (const release of releases.values()) {
|
|
5157
|
+
try {
|
|
5158
|
+
await this.withReversionMaintenanceSignal((signal) =>
|
|
5159
|
+
this.turnReversionProvider!.release(Object.freeze({
|
|
5160
|
+
scopeId,
|
|
5161
|
+
scope,
|
|
5162
|
+
storageKey,
|
|
5163
|
+
sessionId,
|
|
5164
|
+
runId: release.runId,
|
|
5165
|
+
captureId: release.captureId,
|
|
5166
|
+
reference: cloneSerializable(release.reference),
|
|
5167
|
+
signal,
|
|
5168
|
+
})));
|
|
5169
|
+
} catch (error) {
|
|
5170
|
+
throw this.projectOperationError(
|
|
5171
|
+
error,
|
|
5172
|
+
'turnReversion',
|
|
5173
|
+
scopeId,
|
|
5174
|
+
sessionId,
|
|
5175
|
+
`release:${release.captureId}`,
|
|
5176
|
+
);
|
|
5177
|
+
}
|
|
5178
|
+
}
|
|
3028
5179
|
}
|
|
3029
5180
|
|
|
3030
5181
|
private async promoteCompletedTerminal(
|
|
@@ -3644,6 +5795,13 @@ export class FlexHarness<TScope = unknown> {
|
|
|
3644
5795
|
await this.mutateProjection(run.state, run.stored, () => {
|
|
3645
5796
|
run.stored.messages = run.stored.messages.filter((message) => message.runId !== run.runId);
|
|
3646
5797
|
run.stored.stagedTerminals = run.stored.stagedTerminals.filter((entry) => entry.runId !== run.runId);
|
|
5798
|
+
run.stored.reversionSegments = run.stored.reversionSegments.filter(
|
|
5799
|
+
(segment) => segment.runId !== run.runId,
|
|
5800
|
+
);
|
|
5801
|
+
if (
|
|
5802
|
+
run.stored.pendingReversion?.kind === 'capture'
|
|
5803
|
+
&& run.stored.pendingReversion.runId === run.runId
|
|
5804
|
+
) delete run.stored.pendingReversion;
|
|
3647
5805
|
}, true);
|
|
3648
5806
|
} catch (error) {
|
|
3649
5807
|
errors.push(this.projectExternalError(run, error, 'persistence'));
|
|
@@ -3684,6 +5842,161 @@ export class FlexHarness<TScope = unknown> {
|
|
|
3684
5842
|
}
|
|
3685
5843
|
}
|
|
3686
5844
|
|
|
5845
|
+
private async provideRunToolHandle(
|
|
5846
|
+
run: IActiveRun,
|
|
5847
|
+
context: Readonly<IFlexToolProviderContext<TScope>>,
|
|
5848
|
+
): Promise<IFlexToolHandle | undefined> {
|
|
5849
|
+
if (!this.resourceToolProviderResolver) return this.toolProvider?.provideTools(context);
|
|
5850
|
+
|
|
5851
|
+
const resolverContext = Object.freeze({
|
|
5852
|
+
scopeId: context.scopeId,
|
|
5853
|
+
scope: context.scope,
|
|
5854
|
+
sessionId: context.sessionId,
|
|
5855
|
+
runId: context.runId,
|
|
5856
|
+
...(context.parentSessionId === undefined ? {} : { parentSessionId: context.parentSessionId }),
|
|
5857
|
+
...(context.agent === undefined ? {} : { agent: context.agent }),
|
|
5858
|
+
signal: context.signal,
|
|
5859
|
+
});
|
|
5860
|
+
const resolved = await this.resourceToolProviderResolver.resolveResourceToolProviders(resolverContext);
|
|
5861
|
+
const descriptors = this.normalizeResourceToolProviderDescriptors(resolved);
|
|
5862
|
+
const owned: Array<{ handle: IFlexToolHandle; closed: boolean }> = [];
|
|
5863
|
+
const tools: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
5864
|
+
const closeOwned = async (): Promise<void> => {
|
|
5865
|
+
const errors: unknown[] = [];
|
|
5866
|
+
for (const entry of [...owned].reverse()) {
|
|
5867
|
+
if (entry.closed || !entry.handle.close) continue;
|
|
5868
|
+
try {
|
|
5869
|
+
await entry.handle.close();
|
|
5870
|
+
entry.closed = true;
|
|
5871
|
+
} catch (error) {
|
|
5872
|
+
errors.push(error);
|
|
5873
|
+
}
|
|
5874
|
+
}
|
|
5875
|
+
if (errors.length > 0) throw combineErrors(errors);
|
|
5876
|
+
};
|
|
5877
|
+
try {
|
|
5878
|
+
context.signal.throwIfAborted();
|
|
5879
|
+
const applicationHandle = this.normalizeToolHandle(
|
|
5880
|
+
run,
|
|
5881
|
+
await this.toolProvider?.provideTools(context),
|
|
5882
|
+
);
|
|
5883
|
+
if (applicationHandle) {
|
|
5884
|
+
owned.push({ handle: applicationHandle, closed: false });
|
|
5885
|
+
for (const [toolName, tool] of Object.entries(applicationHandle.tools)) {
|
|
5886
|
+
tools[toolName] = tool;
|
|
5887
|
+
}
|
|
5888
|
+
}
|
|
5889
|
+
for (const descriptor of descriptors) {
|
|
5890
|
+
context.signal.throwIfAborted();
|
|
5891
|
+
const namespace = createFlexResourceToolNamespace(
|
|
5892
|
+
descriptor.resourceId,
|
|
5893
|
+
descriptor.attachmentRevision,
|
|
5894
|
+
);
|
|
5895
|
+
const resourceIdentity = createFlexResourceIdentityDigest(
|
|
5896
|
+
descriptor.resourceId,
|
|
5897
|
+
descriptor.attachmentRevision,
|
|
5898
|
+
);
|
|
5899
|
+
const resourceContext = Object.freeze({
|
|
5900
|
+
...context,
|
|
5901
|
+
requestPermission: (request: IFlexPermissionRequestInput) => context.requestPermission({
|
|
5902
|
+
...request,
|
|
5903
|
+
kind: `resource.${resourceIdentity}.${request.kind}`,
|
|
5904
|
+
...(request.rememberKey === undefined
|
|
5905
|
+
? {}
|
|
5906
|
+
: { rememberKey: `resource:${resourceIdentity}:${request.rememberKey}` }),
|
|
5907
|
+
metadata: {
|
|
5908
|
+
resourceId: descriptor.resourceId,
|
|
5909
|
+
attachmentRevision: descriptor.attachmentRevision,
|
|
5910
|
+
resourceIdentity,
|
|
5911
|
+
toolNamespace: namespace,
|
|
5912
|
+
...(request.metadata === undefined ? {} : { providerMetadata: request.metadata }),
|
|
5913
|
+
},
|
|
5914
|
+
}),
|
|
5915
|
+
});
|
|
5916
|
+
const handle = this.normalizeToolHandle(
|
|
5917
|
+
run,
|
|
5918
|
+
await descriptor.provider.provideTools(resourceContext),
|
|
5919
|
+
);
|
|
5920
|
+
if (!handle) continue;
|
|
5921
|
+
owned.push({ handle, closed: false });
|
|
5922
|
+
for (const [toolName, tool] of Object.entries(handle.tools)) {
|
|
5923
|
+
const exposedName = createFlexResourceToolName(namespace, toolName);
|
|
5924
|
+
if (Object.prototype.hasOwnProperty.call(tools, exposedName)) {
|
|
5925
|
+
throw new FlexHarnessValidationError(`Duplicate exposed tool "${exposedName}".`);
|
|
5926
|
+
}
|
|
5927
|
+
tools[exposedName] = tool;
|
|
5928
|
+
}
|
|
5929
|
+
}
|
|
5930
|
+
if (owned.length === 0 && Object.keys(tools).length === 0) return undefined;
|
|
5931
|
+
return {
|
|
5932
|
+
tools: tools as TFlexAgentToolSet,
|
|
5933
|
+
...(owned.some((entry) => entry.handle.close) ? { close: closeOwned } : {}),
|
|
5934
|
+
};
|
|
5935
|
+
} catch (error) {
|
|
5936
|
+
if (owned.some((entry) => entry.handle.close && !entry.closed)) {
|
|
5937
|
+
try {
|
|
5938
|
+
await this.closePartialToolHandle({ tools: {}, close: closeOwned }, run);
|
|
5939
|
+
} catch (cleanupError) {
|
|
5940
|
+
throw combineErrors([error, cleanupError]);
|
|
5941
|
+
}
|
|
5942
|
+
}
|
|
5943
|
+
throw error;
|
|
5944
|
+
}
|
|
5945
|
+
}
|
|
5946
|
+
|
|
5947
|
+
private normalizeResourceToolProviderDescriptors(
|
|
5948
|
+
descriptors: readonly IFlexResourceToolProviderDescriptor<TScope>[],
|
|
5949
|
+
): IFlexResourceToolProviderDescriptor<TScope>[] {
|
|
5950
|
+
if (!Array.isArray(descriptors) || descriptors.length > maxResourceToolProviders) {
|
|
5951
|
+
throw new FlexHarnessValidationError(
|
|
5952
|
+
`Resource tool providers must be an array with at most ${maxResourceToolProviders} descriptors.`,
|
|
5953
|
+
);
|
|
5954
|
+
}
|
|
5955
|
+
const resourceIds = new Set<string>();
|
|
5956
|
+
const namespaces = new Set<string>();
|
|
5957
|
+
return descriptors.map((descriptor, index) => {
|
|
5958
|
+
if (
|
|
5959
|
+
!descriptor
|
|
5960
|
+
|| typeof descriptor !== 'object'
|
|
5961
|
+
|| Array.isArray(descriptor)
|
|
5962
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(descriptor))
|
|
5963
|
+
) {
|
|
5964
|
+
throw new FlexHarnessValidationError(`Resource tool provider descriptor ${index} is invalid.`);
|
|
5965
|
+
}
|
|
5966
|
+
const unsupported = Object.keys(descriptor).find(
|
|
5967
|
+
(key) => !['resourceId', 'attachmentRevision', 'provider'].includes(key),
|
|
5968
|
+
);
|
|
5969
|
+
if (unsupported) {
|
|
5970
|
+
throw new FlexHarnessValidationError(
|
|
5971
|
+
`Resource tool provider descriptor ${index} does not support "${unsupported}".`,
|
|
5972
|
+
);
|
|
5973
|
+
}
|
|
5974
|
+
const resourceId = descriptor.resourceId;
|
|
5975
|
+
const attachmentRevision = descriptor.attachmentRevision;
|
|
5976
|
+
const provider = descriptor.provider;
|
|
5977
|
+
const provideTools = provider?.provideTools;
|
|
5978
|
+
const namespace = createFlexResourceToolNamespace(resourceId, attachmentRevision);
|
|
5979
|
+
if (resourceIds.has(resourceId)) {
|
|
5980
|
+
throw new FlexHarnessValidationError(`Duplicate resourceId "${resourceId}".`);
|
|
5981
|
+
}
|
|
5982
|
+
if (namespaces.has(namespace)) {
|
|
5983
|
+
throw new FlexHarnessValidationError(`Duplicate resource tool namespace "${namespace}".`);
|
|
5984
|
+
}
|
|
5985
|
+
if (!provider || typeof provideTools !== 'function') {
|
|
5986
|
+
throw new FlexHarnessValidationError(`Resource tool provider descriptor ${index} has no tool provider.`);
|
|
5987
|
+
}
|
|
5988
|
+
resourceIds.add(resourceId);
|
|
5989
|
+
namespaces.add(namespace);
|
|
5990
|
+
return {
|
|
5991
|
+
resourceId,
|
|
5992
|
+
attachmentRevision,
|
|
5993
|
+
provider: {
|
|
5994
|
+
provideTools: (context) => provideTools.call(provider, context),
|
|
5995
|
+
},
|
|
5996
|
+
};
|
|
5997
|
+
});
|
|
5998
|
+
}
|
|
5999
|
+
|
|
3687
6000
|
private async closePartialToolHandle(handle: IFlexToolHandle, run: IActiveRun): Promise<void> {
|
|
3688
6001
|
try {
|
|
3689
6002
|
await handle.close?.();
|
|
@@ -3861,6 +6174,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
3861
6174
|
storageKey,
|
|
3862
6175
|
compactorLifecycleController,
|
|
3863
6176
|
scopeIdHint: scopeId,
|
|
6177
|
+
scopeContext: { scopeId, scope },
|
|
3864
6178
|
revision: snapshot.revision,
|
|
3865
6179
|
sessions: new Map(),
|
|
3866
6180
|
retainedSessionCleanups: new Map(),
|
|
@@ -3885,7 +6199,14 @@ export class FlexHarness<TScope = unknown> {
|
|
|
3885
6199
|
const stored = await this.loadSessionRuntime(state, metadata, scopeId, scope);
|
|
3886
6200
|
state.sessions.set(metadata.sessionId, stored);
|
|
3887
6201
|
loadedSessions.push(stored);
|
|
3888
|
-
scopeChanged = (await this.repairLoadedSession(
|
|
6202
|
+
scopeChanged = (await this.repairLoadedSession(
|
|
6203
|
+
state.storageKey,
|
|
6204
|
+
stored,
|
|
6205
|
+
state,
|
|
6206
|
+
scopeId,
|
|
6207
|
+
scope,
|
|
6208
|
+
)) || scopeChanged;
|
|
6209
|
+
await this.drainReversionReleases(state, stored, scopeId, scope, false);
|
|
3889
6210
|
if (stored.projectionRevision < 0) throw new Error('Invalid projection revision.');
|
|
3890
6211
|
}
|
|
3891
6212
|
for (const stored of state.sessions.values()) {
|
|
@@ -3916,6 +6237,13 @@ export class FlexHarness<TScope = unknown> {
|
|
|
3916
6237
|
try {
|
|
3917
6238
|
const group = this.tombstoneGroup(state, rootSessionId);
|
|
3918
6239
|
for (const tombstone of group) {
|
|
6240
|
+
await this.releaseDeletedSessionReversions(
|
|
6241
|
+
state,
|
|
6242
|
+
storageKey,
|
|
6243
|
+
tombstone.sessionId,
|
|
6244
|
+
scopeId,
|
|
6245
|
+
scope,
|
|
6246
|
+
);
|
|
3919
6247
|
await this.cleanupSessionDomains(storageKey, tombstone.sessionId);
|
|
3920
6248
|
}
|
|
3921
6249
|
for (const tombstone of group) state.tombstones.delete(tombstone.sessionId);
|
|
@@ -3931,6 +6259,16 @@ export class FlexHarness<TScope = unknown> {
|
|
|
3931
6259
|
}
|
|
3932
6260
|
return state;
|
|
3933
6261
|
} catch (error) {
|
|
6262
|
+
for (const rootSessionId of new Set([...state.tombstones.values()].map((tombstone) =>
|
|
6263
|
+
tombstone.rootSessionId ?? tombstone.sessionId))) {
|
|
6264
|
+
const key = JSON.stringify([storageKey, rootSessionId]);
|
|
6265
|
+
this.orphanedTombstoneOwners.set(key, {
|
|
6266
|
+
state,
|
|
6267
|
+
rootSessionId,
|
|
6268
|
+
scopeId,
|
|
6269
|
+
scope,
|
|
6270
|
+
});
|
|
6271
|
+
}
|
|
3934
6272
|
const closeResults = await Promise.allSettled(
|
|
3935
6273
|
loadedSessions.map((stored) => this.closeStoredSession(stored)),
|
|
3936
6274
|
);
|
|
@@ -3963,6 +6301,12 @@ export class FlexHarness<TScope = unknown> {
|
|
|
3963
6301
|
session,
|
|
3964
6302
|
messages: [],
|
|
3965
6303
|
stagedTerminals: [],
|
|
6304
|
+
reversionSegments: [],
|
|
6305
|
+
revertCursor: 0,
|
|
6306
|
+
excludedRunIds: [],
|
|
6307
|
+
pendingReversionReleases: [],
|
|
6308
|
+
projectionSchemaVersion: 2,
|
|
6309
|
+
projectionReconciliationRequired: false,
|
|
3966
6310
|
projectionRevision: 0,
|
|
3967
6311
|
projectionQueue: Promise.resolve(),
|
|
3968
6312
|
rememberedPermissionKeys: new Set(),
|
|
@@ -4029,6 +6373,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4029
6373
|
throw combineErrors(acquisitionErrors);
|
|
4030
6374
|
}
|
|
4031
6375
|
const projection = projectionResult.value;
|
|
6376
|
+
const projectionV2 = projection?.schemaVersion === 2 ? projection : undefined;
|
|
4032
6377
|
const permission = permissionResult.value;
|
|
4033
6378
|
const eventStore = eventStoreResult.value;
|
|
4034
6379
|
const jobStore = jobStoreResult.value;
|
|
@@ -4059,6 +6404,20 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4059
6404
|
session: cloneSerializable(metadata),
|
|
4060
6405
|
messages: cloneSerializable(projection?.messages ?? []),
|
|
4061
6406
|
stagedTerminals: cloneSerializable(projection?.stagedTerminals ?? []),
|
|
6407
|
+
reversionSegments: cloneSerializable(projectionV2?.reversionSegments ?? []),
|
|
6408
|
+
revertCursor: projectionV2?.revertCursor ?? 0,
|
|
6409
|
+
excludedRunIds: cloneSerializable(projectionV2?.excludedRunIds ?? []),
|
|
6410
|
+
...(projectionV2?.pendingReversion === undefined
|
|
6411
|
+
? {}
|
|
6412
|
+
: { pendingReversion: cloneSerializable(projectionV2.pendingReversion) }),
|
|
6413
|
+
pendingReversionReleases: cloneSerializable(
|
|
6414
|
+
projectionV2?.pendingReversionReleases ?? [],
|
|
6415
|
+
),
|
|
6416
|
+
projectionSchemaVersion: projection?.schemaVersion ?? 2,
|
|
6417
|
+
projectionReconciliationRequired: false,
|
|
6418
|
+
...(projection?.schemaVersion === 1
|
|
6419
|
+
? { projectionBaseline: cloneSerializable(projection) }
|
|
6420
|
+
: {}),
|
|
4062
6421
|
projectionRevision: projection?.revision ?? 0,
|
|
4063
6422
|
projectionQueue: Promise.resolve(),
|
|
4064
6423
|
rememberedPermissionKeys: new Set(permission?.rememberedPermissionKeys ?? []),
|
|
@@ -4107,7 +6466,9 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4107
6466
|
eventStore,
|
|
4108
6467
|
executionContext: contextualExecutionContext,
|
|
4109
6468
|
contextBuilder: ({ events }) => hydrateAgentMessages(
|
|
4110
|
-
(this.agentSessionPolicy.contextBuilder ?? ((options) => plugins.buildModelMessages(options.events)))({
|
|
6469
|
+
(this.agentSessionPolicy.contextBuilder ?? ((options) => plugins.buildModelMessages(options.events)))({
|
|
6470
|
+
events: this.filterReversionContextEvents(stored, events),
|
|
6471
|
+
}),
|
|
4111
6472
|
),
|
|
4112
6473
|
...(contextCompactor
|
|
4113
6474
|
? {
|
|
@@ -4124,7 +6485,14 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4124
6485
|
) {
|
|
4125
6486
|
throw new Error('Agent context compaction is missing its exact FlexHarness invocation context.');
|
|
4126
6487
|
}
|
|
4127
|
-
|
|
6488
|
+
const filteredEvents = this.filterReversionContextEvents(stored, events);
|
|
6489
|
+
const filteredMessages = hydrateAgentMessages(
|
|
6490
|
+
(this.agentSessionPolicy.contextBuilder
|
|
6491
|
+
?? ((contextOptions) => plugins.buildModelMessages(contextOptions.events)))({
|
|
6492
|
+
events: filteredEvents,
|
|
6493
|
+
}),
|
|
6494
|
+
);
|
|
6495
|
+
return contextCompactor(filteredMessages, filteredEvents, {
|
|
4128
6496
|
...options,
|
|
4129
6497
|
...invocationContext,
|
|
4130
6498
|
scope: invocationContext.scope as TScope,
|
|
@@ -4191,13 +6559,37 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4191
6559
|
private async repairLoadedSession(
|
|
4192
6560
|
storageKey: string,
|
|
4193
6561
|
stored: IStoredSessionState,
|
|
6562
|
+
state?: IStorageState,
|
|
6563
|
+
scopeId?: string,
|
|
6564
|
+
scope?: TScope,
|
|
4194
6565
|
): Promise<boolean> {
|
|
4195
6566
|
const beforeProjection = JSON.stringify({
|
|
4196
6567
|
messages: stored.messages,
|
|
4197
6568
|
stagedTerminals: stored.stagedTerminals,
|
|
6569
|
+
reversionSegments: stored.reversionSegments,
|
|
6570
|
+
revertCursor: stored.revertCursor,
|
|
6571
|
+
excludedRunIds: stored.excludedRunIds,
|
|
6572
|
+
pendingReversion: stored.pendingReversion,
|
|
6573
|
+
pendingReversionReleases: stored.pendingReversionReleases,
|
|
4198
6574
|
});
|
|
4199
6575
|
const beforeSession = JSON.stringify(stored.session);
|
|
6576
|
+
if (
|
|
6577
|
+
!this.turnReversionProvider
|
|
6578
|
+
&& (
|
|
6579
|
+
stored.reversionSegments.some((segment) => segment.captureId)
|
|
6580
|
+
|| stored.pendingReversionReleases.length > 0
|
|
6581
|
+
|| stored.pendingReversion?.kind === 'capture'
|
|
6582
|
+
)
|
|
6583
|
+
) {
|
|
6584
|
+
throw new FlexHarnessValidationError(
|
|
6585
|
+
'Capture-backed session recovery requires its turn reversion provider.',
|
|
6586
|
+
);
|
|
6587
|
+
}
|
|
4200
6588
|
const outcomes = this.canonicalOutcomes(stored.agentSession.getEvents());
|
|
6589
|
+
const firstHiddenCandidate = this.completedReversionCandidates(stored)[stored.revertCursor];
|
|
6590
|
+
const reversionVisibilityBoundary = firstHiddenCandidate
|
|
6591
|
+
? stored.reversionSegments.findIndex((segment) => segment.runId === firstHiddenCandidate.runId)
|
|
6592
|
+
: stored.reversionSegments.length;
|
|
4201
6593
|
const stages = new Map(stored.stagedTerminals.map((terminal) => [terminal.runId, terminal]));
|
|
4202
6594
|
const runIds = new Set([
|
|
4203
6595
|
...stored.messages.map((message) => message.runId),
|
|
@@ -4234,6 +6626,37 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4234
6626
|
}
|
|
4235
6627
|
stored.stagedTerminals = stored.stagedTerminals.filter((terminal) => !outcomes.has(terminal.runId));
|
|
4236
6628
|
if (stored.stagedTerminals.length > 0) stored.stagedTerminals = [];
|
|
6629
|
+
const activeEvents = stored.agentSession.getEvents();
|
|
6630
|
+
const activeEventIds = new Set(activeEvents.map((event) => event.id));
|
|
6631
|
+
for (let index = 0; index < stored.reversionSegments.length; index++) {
|
|
6632
|
+
const segment = stored.reversionSegments[index];
|
|
6633
|
+
if (segment.eventIds.length > 0 && !segment.eventIds.some((id) => activeEventIds.has(id))) {
|
|
6634
|
+
segment.contextAvailable = false;
|
|
6635
|
+
}
|
|
6636
|
+
if (segment.status !== 'capturing') {
|
|
6637
|
+
const outcome = outcomes.get(segment.runId);
|
|
6638
|
+
if (outcome === 'accepted') segment.status = 'completed';
|
|
6639
|
+
else if (outcome === 'rejected') segment.status = 'failed';
|
|
6640
|
+
else if (outcome === 'interrupted' || segment.contextAvailable) segment.status = 'cancelled';
|
|
6641
|
+
}
|
|
6642
|
+
}
|
|
6643
|
+
stored.revertCursor = stored.reversionSegments
|
|
6644
|
+
.slice(0, reversionVisibilityBoundary)
|
|
6645
|
+
.filter((segment) => segment.status === 'completed')
|
|
6646
|
+
.length;
|
|
6647
|
+
this.commitArchivedHiddenBranchState(stored);
|
|
6648
|
+
this.pruneReversionState(stored, false, true);
|
|
6649
|
+
if (stored.pendingReversion?.kind === 'apply') {
|
|
6650
|
+
if (!state || scopeId === undefined || scope === undefined) {
|
|
6651
|
+
throw new FlexHarnessValidationError('Pending reversion recovery is missing its scope context.');
|
|
6652
|
+
}
|
|
6653
|
+
await this.resumePendingApply(state, stored, scopeId, scope);
|
|
6654
|
+
} else if (stored.pendingReversion?.kind === 'capture') {
|
|
6655
|
+
if (!this.turnReversionProvider || !state || scopeId === undefined || scope === undefined) {
|
|
6656
|
+
throw new FlexHarnessValidationError('Pending capture recovery requires its reversion provider.');
|
|
6657
|
+
}
|
|
6658
|
+
await this.recoverPendingCapture(state, stored, scopeId, scope, outcomes);
|
|
6659
|
+
}
|
|
4237
6660
|
if (latestTerminal) {
|
|
4238
6661
|
const completedAt = latestTerminal.assistantMessage.completedAt ?? new Date().toISOString();
|
|
4239
6662
|
stored.session.status = latestTerminal.status === 'completed' ? 'idle' : latestTerminal.status;
|
|
@@ -4260,6 +6683,11 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4260
6683
|
const projectionChanged = beforeProjection !== JSON.stringify({
|
|
4261
6684
|
messages: stored.messages,
|
|
4262
6685
|
stagedTerminals: stored.stagedTerminals,
|
|
6686
|
+
reversionSegments: stored.reversionSegments,
|
|
6687
|
+
revertCursor: stored.revertCursor,
|
|
6688
|
+
excludedRunIds: stored.excludedRunIds,
|
|
6689
|
+
pendingReversion: stored.pendingReversion,
|
|
6690
|
+
pendingReversionReleases: stored.pendingReversionReleases,
|
|
4263
6691
|
});
|
|
4264
6692
|
if (projectionChanged) {
|
|
4265
6693
|
const expected = stored.projectionRevision;
|
|
@@ -4270,6 +6698,9 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4270
6698
|
snapshot,
|
|
4271
6699
|
expected,
|
|
4272
6700
|
);
|
|
6701
|
+
stored.projectionSchemaVersion = 2;
|
|
6702
|
+
delete stored.projectionBaseline;
|
|
6703
|
+
stored.projectionReconciliationRequired = false;
|
|
4273
6704
|
stored.projectionRevision = snapshot.revision;
|
|
4274
6705
|
}
|
|
4275
6706
|
return beforeSession !== JSON.stringify(stored.session);
|
|
@@ -4429,18 +6860,47 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4429
6860
|
): Promise<TValue> {
|
|
4430
6861
|
const operation = stored.projectionQueue.then(async () => {
|
|
4431
6862
|
const beforeRevision = stored.projectionRevision;
|
|
6863
|
+
const beforeSchemaVersion = stored.projectionSchemaVersion;
|
|
6864
|
+
const beforeReconciliationRequired = stored.projectionReconciliationRequired;
|
|
6865
|
+
const beforeBaseline = stored.projectionBaseline === undefined
|
|
6866
|
+
? undefined
|
|
6867
|
+
: cloneSerializable(stored.projectionBaseline);
|
|
4432
6868
|
const beforeMessages = cloneSerializable(stored.messages);
|
|
4433
6869
|
const beforeStages = cloneSerializable(stored.stagedTerminals);
|
|
4434
|
-
const
|
|
4435
|
-
|
|
6870
|
+
const beforeSegments = cloneSerializable(stored.reversionSegments);
|
|
6871
|
+
const beforeCursor = stored.revertCursor;
|
|
6872
|
+
const beforeExcludedRunIds = cloneSerializable(stored.excludedRunIds);
|
|
6873
|
+
const beforePendingReversion = stored.pendingReversion === undefined
|
|
6874
|
+
? undefined
|
|
6875
|
+
: cloneSerializable(stored.pendingReversion);
|
|
6876
|
+
const beforePendingReversionReleases = cloneSerializable(stored.pendingReversionReleases);
|
|
6877
|
+
const beforeSnapshot: TFlexProjectionSnapshot = beforeBaseline ?? {
|
|
6878
|
+
schemaVersion: 2,
|
|
4436
6879
|
revision: beforeRevision,
|
|
4437
6880
|
messages: cloneSerializable(beforeMessages),
|
|
4438
6881
|
stagedTerminals: cloneSerializable(beforeStages),
|
|
6882
|
+
reversionSegments: cloneSerializable(beforeSegments),
|
|
6883
|
+
revertCursor: beforeCursor,
|
|
6884
|
+
excludedRunIds: cloneSerializable(beforeExcludedRunIds),
|
|
6885
|
+
...(beforePendingReversion === undefined
|
|
6886
|
+
? {}
|
|
6887
|
+
: { pendingReversion: cloneSerializable(beforePendingReversion) }),
|
|
6888
|
+
pendingReversionReleases: cloneSerializable(beforePendingReversionReleases),
|
|
4439
6889
|
};
|
|
4440
6890
|
const restore = () => {
|
|
6891
|
+
stored.projectionSchemaVersion = beforeSchemaVersion;
|
|
6892
|
+
stored.projectionReconciliationRequired = beforeReconciliationRequired;
|
|
6893
|
+
if (beforeBaseline === undefined) delete stored.projectionBaseline;
|
|
6894
|
+
else stored.projectionBaseline = beforeBaseline;
|
|
4441
6895
|
stored.projectionRevision = beforeRevision;
|
|
4442
6896
|
stored.messages = beforeMessages;
|
|
4443
6897
|
stored.stagedTerminals = beforeStages;
|
|
6898
|
+
stored.reversionSegments = beforeSegments;
|
|
6899
|
+
stored.revertCursor = beforeCursor;
|
|
6900
|
+
stored.excludedRunIds = beforeExcludedRunIds;
|
|
6901
|
+
if (beforePendingReversion === undefined) delete stored.pendingReversion;
|
|
6902
|
+
else stored.pendingReversion = beforePendingReversion;
|
|
6903
|
+
stored.pendingReversionReleases = beforePendingReversionReleases;
|
|
4444
6904
|
};
|
|
4445
6905
|
let result: TValue;
|
|
4446
6906
|
try {
|
|
@@ -4457,6 +6917,9 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4457
6917
|
snapshot,
|
|
4458
6918
|
beforeRevision,
|
|
4459
6919
|
);
|
|
6920
|
+
stored.projectionSchemaVersion = 2;
|
|
6921
|
+
delete stored.projectionBaseline;
|
|
6922
|
+
stored.projectionReconciliationRequired = false;
|
|
4460
6923
|
stored.projectionRevision = snapshot.revision;
|
|
4461
6924
|
return result;
|
|
4462
6925
|
} catch (error) {
|
|
@@ -4465,11 +6928,13 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4465
6928
|
throw error;
|
|
4466
6929
|
}
|
|
4467
6930
|
if (error instanceof FlexHarnessStoreCommitUncertainError) {
|
|
6931
|
+
stored.projectionSchemaVersion = 2;
|
|
6932
|
+
stored.projectionReconciliationRequired = true;
|
|
4468
6933
|
stored.projectionRevision = snapshot.revision;
|
|
4469
6934
|
state.lifecycle = 'fenced';
|
|
4470
6935
|
throw error;
|
|
4471
6936
|
}
|
|
4472
|
-
let current:
|
|
6937
|
+
let current: TFlexProjectionSnapshot | undefined;
|
|
4473
6938
|
let reconciliationError: unknown;
|
|
4474
6939
|
try {
|
|
4475
6940
|
current = await this.stores.projections.load(
|
|
@@ -4480,6 +6945,9 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4480
6945
|
reconciliationError = loadError;
|
|
4481
6946
|
}
|
|
4482
6947
|
if (current && JSON.stringify(current) === JSON.stringify(snapshot)) {
|
|
6948
|
+
stored.projectionSchemaVersion = 2;
|
|
6949
|
+
delete stored.projectionBaseline;
|
|
6950
|
+
stored.projectionReconciliationRequired = false;
|
|
4483
6951
|
stored.projectionRevision = snapshot.revision;
|
|
4484
6952
|
throw error;
|
|
4485
6953
|
}
|
|
@@ -4490,6 +6958,8 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4490
6958
|
restore();
|
|
4491
6959
|
throw error;
|
|
4492
6960
|
}
|
|
6961
|
+
stored.projectionSchemaVersion = 2;
|
|
6962
|
+
stored.projectionReconciliationRequired = true;
|
|
4493
6963
|
stored.projectionRevision = snapshot.revision;
|
|
4494
6964
|
state.lifecycle = 'fenced';
|
|
4495
6965
|
throw reconciliationError === undefined
|
|
@@ -4598,15 +7068,54 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4598
7068
|
private createProjectionSnapshot(
|
|
4599
7069
|
stored: IStoredSessionState,
|
|
4600
7070
|
revision: number,
|
|
4601
|
-
):
|
|
7071
|
+
): IFlexProjectionSnapshotCurrent {
|
|
4602
7072
|
return {
|
|
4603
|
-
schemaVersion:
|
|
7073
|
+
schemaVersion: 2,
|
|
4604
7074
|
revision,
|
|
4605
7075
|
messages: cloneSerializable(stored.messages),
|
|
4606
7076
|
stagedTerminals: cloneSerializable(stored.stagedTerminals),
|
|
7077
|
+
reversionSegments: cloneSerializable(stored.reversionSegments),
|
|
7078
|
+
revertCursor: stored.revertCursor,
|
|
7079
|
+
excludedRunIds: cloneSerializable(stored.excludedRunIds),
|
|
7080
|
+
...(stored.pendingReversion === undefined
|
|
7081
|
+
? {}
|
|
7082
|
+
: { pendingReversion: cloneSerializable(stored.pendingReversion) }),
|
|
7083
|
+
pendingReversionReleases: cloneSerializable(stored.pendingReversionReleases),
|
|
4607
7084
|
};
|
|
4608
7085
|
}
|
|
4609
7086
|
|
|
7087
|
+
private async reconcileProjectionFromStore(stored: IStoredSessionState): Promise<void> {
|
|
7088
|
+
if (!stored.projectionReconciliationRequired) return;
|
|
7089
|
+
const projection = await this.stores.projections.load(
|
|
7090
|
+
stored.storageKey,
|
|
7091
|
+
stored.session.sessionId,
|
|
7092
|
+
);
|
|
7093
|
+
if (!projection) {
|
|
7094
|
+
throw new FlexHarnessValidationError('Uncertain projection persistence could not be reloaded.');
|
|
7095
|
+
}
|
|
7096
|
+
stored.messages = cloneSerializable(projection.messages);
|
|
7097
|
+
stored.stagedTerminals = cloneSerializable(projection.stagedTerminals);
|
|
7098
|
+
if (projection.schemaVersion === 2) {
|
|
7099
|
+
stored.reversionSegments = cloneSerializable(projection.reversionSegments);
|
|
7100
|
+
stored.revertCursor = projection.revertCursor;
|
|
7101
|
+
stored.excludedRunIds = cloneSerializable(projection.excludedRunIds);
|
|
7102
|
+
if (projection.pendingReversion === undefined) delete stored.pendingReversion;
|
|
7103
|
+
else stored.pendingReversion = cloneSerializable(projection.pendingReversion);
|
|
7104
|
+
stored.pendingReversionReleases = cloneSerializable(projection.pendingReversionReleases);
|
|
7105
|
+
delete stored.projectionBaseline;
|
|
7106
|
+
} else {
|
|
7107
|
+
stored.reversionSegments = [];
|
|
7108
|
+
stored.revertCursor = 0;
|
|
7109
|
+
stored.excludedRunIds = [];
|
|
7110
|
+
delete stored.pendingReversion;
|
|
7111
|
+
stored.pendingReversionReleases = [];
|
|
7112
|
+
stored.projectionBaseline = cloneSerializable(projection);
|
|
7113
|
+
}
|
|
7114
|
+
stored.projectionSchemaVersion = projection.schemaVersion;
|
|
7115
|
+
stored.projectionRevision = projection.revision;
|
|
7116
|
+
stored.projectionReconciliationRequired = false;
|
|
7117
|
+
}
|
|
7118
|
+
|
|
4610
7119
|
private async cleanupSessionDomains(storageKey: string, sessionId: string): Promise<void> {
|
|
4611
7120
|
const results = await Promise.allSettled([
|
|
4612
7121
|
this.stores.agentEvents.deleteSession(storageKey, sessionId),
|
|
@@ -4715,15 +7224,19 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4715
7224
|
}
|
|
4716
7225
|
const group = this.tombstoneGroup(state, rootSessionId);
|
|
4717
7226
|
if (group.length === 0) return;
|
|
7227
|
+
const exactInvocation = invocation ?? {
|
|
7228
|
+
scopeId: state.scopeContext.scopeId,
|
|
7229
|
+
scope: state.scopeContext.scope as TScope,
|
|
7230
|
+
};
|
|
4718
7231
|
const groupIds = new Set(group.map((tombstone) => tombstone.sessionId));
|
|
4719
7232
|
const reason = this.trustInternalError(new FlexHarnessAbortError('The session subtree was deleted.'));
|
|
4720
7233
|
const contextFor = (
|
|
4721
7234
|
sessionId: string,
|
|
4722
7235
|
retained?: IRetainedSessionCleanup,
|
|
4723
|
-
): IFlexAgentContextInvocation<TScope> | undefined =>
|
|
7236
|
+
): IFlexAgentContextInvocation<TScope> | undefined => exactInvocation
|
|
4724
7237
|
? this.createCompactorContext(
|
|
4725
|
-
|
|
4726
|
-
|
|
7238
|
+
exactInvocation.scopeId,
|
|
7239
|
+
exactInvocation.scope,
|
|
4727
7240
|
state.storageKey,
|
|
4728
7241
|
sessionId,
|
|
4729
7242
|
)
|
|
@@ -4796,6 +7309,21 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4796
7309
|
if (this.hasOrphanedSessionResources(state.storageKey, sessionId)) {
|
|
4797
7310
|
throw new Error(`Session "${sessionId}" still has runtime cleanup ownership.`);
|
|
4798
7311
|
}
|
|
7312
|
+
const releaseContext = exactInvocation ?? retained?.stored.compactorContext as
|
|
7313
|
+
IFlexAgentContextInvocation<TScope> | undefined;
|
|
7314
|
+
if (!releaseContext) {
|
|
7315
|
+
throw new FlexHarnessValidationError(
|
|
7316
|
+
'Tombstone cleanup requires its exact scope context before deleting projection data.',
|
|
7317
|
+
);
|
|
7318
|
+
}
|
|
7319
|
+
await this.releaseDeletedSessionReversions(
|
|
7320
|
+
state,
|
|
7321
|
+
state.storageKey,
|
|
7322
|
+
sessionId,
|
|
7323
|
+
releaseContext.scopeId,
|
|
7324
|
+
releaseContext.scope,
|
|
7325
|
+
retained?.stored,
|
|
7326
|
+
);
|
|
4799
7327
|
await this.cleanupSessionDomains(state.storageKey, sessionId);
|
|
4800
7328
|
if (retained) retained.domainsCompleted = true;
|
|
4801
7329
|
}
|
|
@@ -4983,6 +7511,26 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4983
7511
|
void drain.catch(() => undefined);
|
|
4984
7512
|
}
|
|
4985
7513
|
|
|
7514
|
+
private deferReversionReleaseDrain(
|
|
7515
|
+
state: IStorageState,
|
|
7516
|
+
stored: IStoredSessionState,
|
|
7517
|
+
scopeId: string,
|
|
7518
|
+
scope: TScope,
|
|
7519
|
+
): void {
|
|
7520
|
+
state.lifecycle = 'fenced';
|
|
7521
|
+
const reason = this.trustInternalError(new FlexHarnessAbortError('The session namespace was fenced.'));
|
|
7522
|
+
this.abortCompactorLifecycle(stored, reason);
|
|
7523
|
+
const stateLoad = this.stateLoads.get(state.storageKey);
|
|
7524
|
+
if (!stateLoad) return;
|
|
7525
|
+
const drain = this.drainStorage(
|
|
7526
|
+
state.storageKey,
|
|
7527
|
+
stateLoad,
|
|
7528
|
+
reason,
|
|
7529
|
+
{ scopeId, scope },
|
|
7530
|
+
);
|
|
7531
|
+
void drain.catch(() => undefined);
|
|
7532
|
+
}
|
|
7533
|
+
|
|
4986
7534
|
private sessionsAreDependencyRelated(
|
|
4987
7535
|
state: IStorageState,
|
|
4988
7536
|
leftSessionId: string,
|
|
@@ -5188,6 +7736,20 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5188
7736
|
|
|
5189
7737
|
private async closeOrphanedResourcesInternal(storageKey?: string): Promise<unknown[]> {
|
|
5190
7738
|
const errors: unknown[] = [];
|
|
7739
|
+
for (const [key, owner] of [...this.orphanedTombstoneOwners]) {
|
|
7740
|
+
if (storageKey !== undefined && owner.state.storageKey !== storageKey) continue;
|
|
7741
|
+
try {
|
|
7742
|
+
await this.finishTombstoneCleanup(
|
|
7743
|
+
owner.state,
|
|
7744
|
+
owner.rootSessionId,
|
|
7745
|
+
owner.scopeId,
|
|
7746
|
+
{ scopeId: owner.scopeId, scope: owner.scope },
|
|
7747
|
+
);
|
|
7748
|
+
this.orphanedTombstoneOwners.delete(key);
|
|
7749
|
+
} catch (error) {
|
|
7750
|
+
errors.push(error);
|
|
7751
|
+
}
|
|
7752
|
+
}
|
|
5191
7753
|
const tombstoneCleanups = [...this.orphanedTombstoneCleanups.values()]
|
|
5192
7754
|
.filter((retained) => storageKey === undefined || retained.storageKey === storageKey);
|
|
5193
7755
|
const tombstoneResults = await Promise.allSettled(
|
|
@@ -5237,6 +7799,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5237
7799
|
...[...this.orphanedExecutionContexts.values()].map((owner) => owner.storageKey),
|
|
5238
7800
|
...[...this.orphanedProviderReleases.values()].map((retained) => retained.storageKey),
|
|
5239
7801
|
...[...this.orphanedTombstoneCleanups.values()].map((retained) => retained.storageKey),
|
|
7802
|
+
...[...this.orphanedTombstoneOwners.values()].map((owner) => owner.state.storageKey),
|
|
5240
7803
|
]);
|
|
5241
7804
|
}
|
|
5242
7805
|
|
|
@@ -5244,7 +7807,23 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5244
7807
|
const scope = await this.scopeResolver.resolveScope(scopeId);
|
|
5245
7808
|
this.assertOpen();
|
|
5246
7809
|
validateIdentifier(scope.storageKey, 'resolved storageKey');
|
|
7810
|
+
const invocationOwner = this.slashCommandInvocationContext.getStore();
|
|
7811
|
+
if (invocationOwner?.storageKey === scope.storageKey) {
|
|
7812
|
+
throw this.trustInternalError(
|
|
7813
|
+
new FlexHarnessSlashCommandReentryError(invocationOwner.sessionId),
|
|
7814
|
+
);
|
|
7815
|
+
}
|
|
5247
7816
|
const errors: unknown[] = [];
|
|
7817
|
+
const listingSettlement = this.abortSlashCommandListings(
|
|
7818
|
+
scope.storageKey,
|
|
7819
|
+
this.trustInternalError(new FlexHarnessAbortError(scopeRetirementMessage)),
|
|
7820
|
+
);
|
|
7821
|
+
if (listingSettlement) await listingSettlement;
|
|
7822
|
+
await this.abortSlashCommandExecutions(
|
|
7823
|
+
scope.storageKey,
|
|
7824
|
+
this.trustInternalError(new FlexHarnessAbortError(scopeRetirementMessage)),
|
|
7825
|
+
errors,
|
|
7826
|
+
);
|
|
5248
7827
|
const stateLoad = this.stateLoads.get(scope.storageKey);
|
|
5249
7828
|
if (!stateLoad) {
|
|
5250
7829
|
const drain = this.storageDrains.get(scope.storageKey);
|
|
@@ -5386,6 +7965,21 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5386
7965
|
'lifecycle-close',
|
|
5387
7966
|
));
|
|
5388
7967
|
}
|
|
7968
|
+
const releaseContext = invocation ?? stored.compactorContext as
|
|
7969
|
+
IFlexAgentContextInvocation<TScope> | undefined;
|
|
7970
|
+
if (releaseContext) {
|
|
7971
|
+
try {
|
|
7972
|
+
await this.drainReversionReleases(
|
|
7973
|
+
state,
|
|
7974
|
+
stored,
|
|
7975
|
+
releaseContext.scopeId,
|
|
7976
|
+
releaseContext.scope,
|
|
7977
|
+
true,
|
|
7978
|
+
);
|
|
7979
|
+
} catch (error) {
|
|
7980
|
+
errors.push(error);
|
|
7981
|
+
}
|
|
7982
|
+
}
|
|
5389
7983
|
this.purgeStoredPromptQueue(stored);
|
|
5390
7984
|
}
|
|
5391
7985
|
const roots = this.orderTombstoneRootsChildFirst(
|
|
@@ -5430,6 +8024,21 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5430
8024
|
if (!pending.controller.signal.aborted) pending.controller.abort(pendingAdmissionReason);
|
|
5431
8025
|
}
|
|
5432
8026
|
await Promise.all(pendingAdmissions.map((pending) => pending.settled));
|
|
8027
|
+
const errors: unknown[] = [];
|
|
8028
|
+
const listingSettlement = this.abortSlashCommandListings(
|
|
8029
|
+
undefined,
|
|
8030
|
+
this.trustInternalError(new FlexHarnessAbortError(
|
|
8031
|
+
'The slash command listing was aborted because FlexHarness was disposed.',
|
|
8032
|
+
)),
|
|
8033
|
+
);
|
|
8034
|
+
if (listingSettlement) await listingSettlement;
|
|
8035
|
+
await this.abortSlashCommandExecutions(
|
|
8036
|
+
undefined,
|
|
8037
|
+
this.trustInternalError(new FlexHarnessAbortError(
|
|
8038
|
+
'The slash command was aborted because FlexHarness was disposed.',
|
|
8039
|
+
)),
|
|
8040
|
+
errors,
|
|
8041
|
+
);
|
|
5433
8042
|
const loads = [...this.stateLoads.entries()];
|
|
5434
8043
|
const results = await Promise.allSettled(loads.map(([storageKey, stateLoad]) =>
|
|
5435
8044
|
this.drainStorage(
|
|
@@ -5439,7 +8048,6 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5439
8048
|
'The run was aborted because FlexHarness was disposed.',
|
|
5440
8049
|
)),
|
|
5441
8050
|
)));
|
|
5442
|
-
const errors: unknown[] = [];
|
|
5443
8051
|
for (const result of results) {
|
|
5444
8052
|
if (result.status === 'rejected') this.appendUnexpectedErrors(errors, result.reason);
|
|
5445
8053
|
}
|
|
@@ -5452,6 +8060,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5452
8060
|
this.listeners.clear();
|
|
5453
8061
|
if (errors.length > 0) throw combineErrors(errors);
|
|
5454
8062
|
this.compactorInvocationContext.disable();
|
|
8063
|
+
this.slashCommandInvocationContext.disable();
|
|
5455
8064
|
this.stateLoads.clear();
|
|
5456
8065
|
this.scopeAdmissions.clear();
|
|
5457
8066
|
this.scopeRetirements.clear();
|
|
@@ -5459,6 +8068,45 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5459
8068
|
this.storageCompactorLifecycleControllers.clear();
|
|
5460
8069
|
}
|
|
5461
8070
|
|
|
8071
|
+
private async abortSlashCommandExecutions(
|
|
8072
|
+
storageKey: string | undefined,
|
|
8073
|
+
reason: FlexHarnessAbortError,
|
|
8074
|
+
errors: unknown[],
|
|
8075
|
+
sessionId?: string,
|
|
8076
|
+
): Promise<void> {
|
|
8077
|
+
const active = [...this.activeSlashCommandExecutions.values()]
|
|
8078
|
+
.filter((execution) =>
|
|
8079
|
+
(storageKey === undefined || execution.storageKey === storageKey)
|
|
8080
|
+
&& (sessionId === undefined || execution.sessionId === sessionId));
|
|
8081
|
+
for (const execution of active) {
|
|
8082
|
+
if (!execution.controller.signal.aborted) execution.controller.abort(reason);
|
|
8083
|
+
}
|
|
8084
|
+
const awaited = active.filter((execution) => execution.kind !== 'prompt-admission');
|
|
8085
|
+
const results = await Promise.allSettled(awaited.map((execution) => execution.completion));
|
|
8086
|
+
for (let index = 0; index < results.length; index++) {
|
|
8087
|
+
const result = results[index];
|
|
8088
|
+
if (result.status === 'rejected' && awaited[index].kind === 'handler') {
|
|
8089
|
+
this.appendUnexpectedErrors(errors, result.reason);
|
|
8090
|
+
}
|
|
8091
|
+
}
|
|
8092
|
+
}
|
|
8093
|
+
|
|
8094
|
+
private abortSlashCommandListings(
|
|
8095
|
+
storageKey: string | undefined,
|
|
8096
|
+
reason: FlexHarnessAbortError,
|
|
8097
|
+
sessionId?: string,
|
|
8098
|
+
): Promise<void> | undefined {
|
|
8099
|
+
const active = [...this.activeSlashCommandListings]
|
|
8100
|
+
.filter((listing) =>
|
|
8101
|
+
(storageKey === undefined || listing.storageKey === storageKey)
|
|
8102
|
+
&& (sessionId === undefined || listing.sessionId === sessionId));
|
|
8103
|
+
if (active.length === 0) return undefined;
|
|
8104
|
+
for (const listing of active) {
|
|
8105
|
+
if (!listing.controller.signal.aborted) listing.controller.abort(reason);
|
|
8106
|
+
}
|
|
8107
|
+
return Promise.allSettled(active.map((listing) => listing.completion)).then(() => undefined);
|
|
8108
|
+
}
|
|
8109
|
+
|
|
5462
8110
|
private appendUnexpectedErrors(target: unknown[], error: unknown): void {
|
|
5463
8111
|
if (error instanceof FlexHarnessRunError) {
|
|
5464
8112
|
for (const nested of error.errors) this.appendUnexpectedErrors(target, nested);
|
|
@@ -5606,6 +8254,18 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5606
8254
|
return stored;
|
|
5607
8255
|
}
|
|
5608
8256
|
|
|
8257
|
+
private completedReversionCandidates(stored: IStoredSessionState): IFlexReversionSegment[] {
|
|
8258
|
+
return stored.reversionSegments.filter((segment) => segment.status === 'completed');
|
|
8259
|
+
}
|
|
8260
|
+
|
|
8261
|
+
private visibleMessages(stored: IStoredSessionState): IFlexMessage[] {
|
|
8262
|
+
const firstHiddenUnit = this.reversionUnit(stored, 'redo');
|
|
8263
|
+
const firstHiddenSegment = firstHiddenUnit?.segments[0];
|
|
8264
|
+
if (!firstHiddenSegment) return stored.messages;
|
|
8265
|
+
const boundary = stored.messages.findIndex((message) => message.runId === firstHiddenSegment.runId);
|
|
8266
|
+
return boundary < 0 ? stored.messages : stored.messages.slice(0, boundary);
|
|
8267
|
+
}
|
|
8268
|
+
|
|
5609
8269
|
private requireMutableSession(state: IStorageState, sessionId: string): IStoredSessionState {
|
|
5610
8270
|
const stored = this.requireSession(state, sessionId);
|
|
5611
8271
|
const pending = [...state.pendingPermissions.values()].some((entry) =>
|
|
@@ -5620,7 +8280,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5620
8280
|
}
|
|
5621
8281
|
|
|
5622
8282
|
private requireMessage(stored: IStoredSessionState, messageId: string): IFlexMessage {
|
|
5623
|
-
const message = stored.
|
|
8283
|
+
const message = this.visibleMessages(stored).find((entry) => entry.messageId === messageId);
|
|
5624
8284
|
if (!message) throw new FlexHarnessNotFoundError('Message', messageId);
|
|
5625
8285
|
return message;
|
|
5626
8286
|
}
|