@modelprofile.com/flexharness 3.6.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 +9 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.flexharness.d.ts +59 -1
- package/dist_ts/classes.flexharness.js +1805 -37
- 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 +188 -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 +3 -0
- package/readme.md +114 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.flexharness.ts +2706 -245
- package/ts/classes.stores.ts +9 -8
- package/ts/errors.ts +25 -0
- package/ts/index.ts +4 -0
- package/ts/interfaces.ts +259 -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,7 +48,11 @@ 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,
|
|
47
58
|
IFlexResourceToolProviderDescriptor,
|
|
@@ -51,12 +62,17 @@ import type {
|
|
|
51
62
|
IFlexScopeSnapshot,
|
|
52
63
|
IFlexSession,
|
|
53
64
|
IFlexSessionTombstone,
|
|
65
|
+
IFlexSlashCommandDescriptor,
|
|
66
|
+
IFlexSlashCommandExecutionOptions,
|
|
67
|
+
IFlexSlashCommandHandlerRegistration,
|
|
68
|
+
IFlexSlashCommandTemplateRegistration,
|
|
54
69
|
IFlexSubagentDefinition,
|
|
55
70
|
IFlexTerminalProjection,
|
|
56
71
|
IFlexToolHandle,
|
|
57
72
|
IFlexToolMessagePart,
|
|
58
73
|
IFlexToolProviderContext,
|
|
59
74
|
IFlexUncertainToolExecution,
|
|
75
|
+
IFlexUndoSessionResult,
|
|
60
76
|
IFlexUpdateSessionOptions,
|
|
61
77
|
IFlexUsage,
|
|
62
78
|
IJsonObject,
|
|
@@ -73,7 +89,12 @@ import type {
|
|
|
73
89
|
TFlexPrompt,
|
|
74
90
|
TFlexPromptPart,
|
|
75
91
|
TFlexPromptQueueStatus,
|
|
92
|
+
TFlexSlashCommandExecutionResult,
|
|
93
|
+
TFlexSlashCommandRegistration,
|
|
94
|
+
TFlexPendingReversion,
|
|
95
|
+
TFlexProjectionSnapshot,
|
|
76
96
|
TFlexToolExecutionReconciliation,
|
|
97
|
+
TJsonValue,
|
|
77
98
|
} from './interfaces.js';
|
|
78
99
|
import { InMemoryFlexHarnessStores } from './classes.stores.js';
|
|
79
100
|
import {
|
|
@@ -88,6 +109,14 @@ import {
|
|
|
88
109
|
normalizeFlexPrompt,
|
|
89
110
|
type INormalizedFlexPrompt,
|
|
90
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';
|
|
91
120
|
|
|
92
121
|
type TCanonicalOutcome = 'accepted' | 'rejected' | 'interrupted';
|
|
93
122
|
type TRunPhase =
|
|
@@ -104,6 +133,14 @@ interface IStoredSessionState {
|
|
|
104
133
|
session: IFlexSession;
|
|
105
134
|
messages: IFlexMessage[];
|
|
106
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;
|
|
107
144
|
projectionRevision: number;
|
|
108
145
|
projectionQueue: Promise<void>;
|
|
109
146
|
rememberedPermissionKeys: Set<string>;
|
|
@@ -151,6 +188,13 @@ interface IOrphanedTombstoneCleanup {
|
|
|
151
188
|
completion: Promise<void>;
|
|
152
189
|
}
|
|
153
190
|
|
|
191
|
+
interface IOrphanedTombstoneOwner<TScope> {
|
|
192
|
+
state: IStorageState;
|
|
193
|
+
rootSessionId: string;
|
|
194
|
+
scopeId: string;
|
|
195
|
+
scope: TScope;
|
|
196
|
+
}
|
|
197
|
+
|
|
154
198
|
interface IOrphanedExecutionContextOwner {
|
|
155
199
|
storageKey: string;
|
|
156
200
|
sessionId: string;
|
|
@@ -160,6 +204,7 @@ interface IStorageState {
|
|
|
160
204
|
storageKey: string;
|
|
161
205
|
compactorLifecycleController: AbortController;
|
|
162
206
|
scopeIdHint: string;
|
|
207
|
+
scopeContext: { scopeId: string; scope: unknown };
|
|
163
208
|
revision: number;
|
|
164
209
|
sessions: Map<string, IStoredSessionState>;
|
|
165
210
|
retainedSessionCleanups: Map<string, IRetainedSessionCleanup>;
|
|
@@ -261,6 +306,32 @@ interface IScopeAdmissionState {
|
|
|
261
306
|
retiring: boolean;
|
|
262
307
|
}
|
|
263
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
|
+
|
|
264
335
|
interface IRunResultProjection {
|
|
265
336
|
text: string;
|
|
266
337
|
steps: number;
|
|
@@ -342,9 +413,14 @@ const maximumMaxSubagentCallsPerRun = 128;
|
|
|
342
413
|
const maxResourceToolProviders = 128;
|
|
343
414
|
const maxResourceIdBytes = 512;
|
|
344
415
|
const maxResourceToolNameBytes = 512;
|
|
416
|
+
const maxSlashCommandRegistrations = 128;
|
|
417
|
+
const maxSlashCommandDescriptionBytes = 2048;
|
|
418
|
+
const maxSlashCommandTemplateBytes = FLEX_SLASH_COMMAND_MAX_INPUT_BYTES;
|
|
345
419
|
const resourceToolStemLength = 16;
|
|
346
420
|
const repairCancellationMessage = 'The process stopped before this run completed.';
|
|
347
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']);
|
|
348
424
|
const externalErrorFallback: IFlexErrorInfo = Object.freeze({
|
|
349
425
|
name: 'FlexHarnessExternalError',
|
|
350
426
|
message: 'The model operation failed.',
|
|
@@ -559,6 +635,89 @@ function normalizeSubagents(
|
|
|
559
635
|
return Object.freeze(normalized);
|
|
560
636
|
}
|
|
561
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
|
+
|
|
562
721
|
function resolveBoundedPositiveInteger(
|
|
563
722
|
value: number | undefined,
|
|
564
723
|
name: string,
|
|
@@ -833,6 +992,31 @@ function validatePromptOptions(options: IFlexPromptOptions, scheduled: boolean):
|
|
|
833
992
|
}
|
|
834
993
|
}
|
|
835
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
|
+
|
|
836
1020
|
function resolveCallbackLimits(limits: IFlexCallbackLimits = {}): Required<IFlexCallbackLimits> {
|
|
837
1021
|
const resolved = {
|
|
838
1022
|
maxEvents: limits.maxEvents ?? DEFAULT_CALLBACK_LIMITS.maxEvents,
|
|
@@ -870,6 +1054,27 @@ function resolvePromptQueueLimits(
|
|
|
870
1054
|
return resolved;
|
|
871
1055
|
}
|
|
872
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
|
+
|
|
873
1078
|
function normalizeAgentSessionPolicy<TScope>(
|
|
874
1079
|
policy: IFlexAgentSessionPolicy<TScope> = {},
|
|
875
1080
|
): IFlexAgentSessionPolicy<TScope> {
|
|
@@ -906,8 +1111,11 @@ export class FlexHarness<TScope = unknown> {
|
|
|
906
1111
|
private readonly toolOutputLimits: Required<NonNullable<IFlexHarnessOptions<TScope>['toolOutputLimits']>>;
|
|
907
1112
|
private readonly callbackLimits: Required<IFlexCallbackLimits>;
|
|
908
1113
|
private readonly promptQueueLimits: Required<IFlexPromptQueueLimits>;
|
|
1114
|
+
private readonly reversionLimits: Required<IFlexReversionLimits>;
|
|
1115
|
+
private readonly turnReversionProvider: IFlexHarnessOptions<TScope>['turnReversionProvider'];
|
|
909
1116
|
private readonly externalErrorProjector?: TFlexExternalErrorProjector;
|
|
910
1117
|
private readonly subagents: ReadonlyMap<string, Readonly<IFlexSubagentDefinition>>;
|
|
1118
|
+
private readonly slashCommands: ReadonlyMap<string, TRegisteredSlashCommand<TScope>>;
|
|
911
1119
|
private readonly maxSubagentDepth: number;
|
|
912
1120
|
private readonly maxSubagentCallsPerRun: number;
|
|
913
1121
|
private readonly stateLoads = new Map<string, Promise<IStorageState>>();
|
|
@@ -925,10 +1133,16 @@ export class FlexHarness<TScope = unknown> {
|
|
|
925
1133
|
>();
|
|
926
1134
|
private readonly orphanedProviderReleases = new Map<string, IOrphanedProviderRelease>();
|
|
927
1135
|
private readonly orphanedTombstoneCleanups = new Map<string, IOrphanedTombstoneCleanup>();
|
|
1136
|
+
private readonly orphanedTombstoneOwners = new Map<string, IOrphanedTombstoneOwner<TScope>>();
|
|
928
1137
|
private readonly pendingPromptAdmissionOwners = new Set<IPendingPromptAdmission>();
|
|
1138
|
+
private readonly activeSlashCommandExecutions = new Map<string, IActiveSlashCommandExecution>();
|
|
1139
|
+
private readonly activeSlashCommandListings = new Set<IActiveSlashCommandListing>();
|
|
929
1140
|
private readonly compactorInvocationContext = new plugins.AsyncLocalStorage<
|
|
930
1141
|
IFlexAgentContextInvocation<TScope>
|
|
931
1142
|
>();
|
|
1143
|
+
private readonly slashCommandInvocationContext = new plugins.AsyncLocalStorage<
|
|
1144
|
+
ISlashCommandInvocationOwner
|
|
1145
|
+
>();
|
|
932
1146
|
private readonly deferredCompactorContexts = new WeakMap<
|
|
933
1147
|
IFlexAgentContextInvocation<unknown>,
|
|
934
1148
|
IFlexAgentContextInvocation<unknown>
|
|
@@ -954,8 +1168,11 @@ export class FlexHarness<TScope = unknown> {
|
|
|
954
1168
|
this.toolOutputLimits = resolveJsonLimits(options.toolOutputLimits);
|
|
955
1169
|
this.callbackLimits = resolveCallbackLimits(options.callbackLimits);
|
|
956
1170
|
this.promptQueueLimits = resolvePromptQueueLimits(options.promptQueueLimits);
|
|
1171
|
+
this.reversionLimits = resolveReversionLimits(options.reversionLimits);
|
|
1172
|
+
this.turnReversionProvider = options.turnReversionProvider;
|
|
957
1173
|
this.externalErrorProjector = options.externalErrorProjector;
|
|
958
1174
|
this.subagents = normalizeSubagents(options.subagents);
|
|
1175
|
+
this.slashCommands = normalizeSlashCommands(options.slashCommands);
|
|
959
1176
|
this.maxSubagentDepth = resolveBoundedPositiveInteger(
|
|
960
1177
|
options.maxSubagentDepth,
|
|
961
1178
|
'maxSubagentDepth',
|
|
@@ -973,6 +1190,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
973
1190
|
public async listSessions(scopeId: string): Promise<IFlexSession[]> {
|
|
974
1191
|
const { state } = await this.resolveState(scopeId);
|
|
975
1192
|
await state.scopeQueue;
|
|
1193
|
+
this.assertOpen();
|
|
976
1194
|
this.assertStateAcceptingWork(state);
|
|
977
1195
|
return publicSnapshot([...state.sessions.values()]
|
|
978
1196
|
.filter((stored) => !state.initializingSessions.has(stored.session.sessionId))
|
|
@@ -1166,7 +1384,14 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1166
1384
|
}
|
|
1167
1385
|
|
|
1168
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
|
+
}
|
|
1169
1391
|
const { scope, state } = await this.resolveState(scopeId);
|
|
1392
|
+
if (invocationOwner?.storageKey === state.storageKey) {
|
|
1393
|
+
throw this.trustInternalError(new FlexHarnessSlashCommandReentryError(invocationOwner.sessionId));
|
|
1394
|
+
}
|
|
1170
1395
|
validateIdentifier(sessionId, 'sessionId');
|
|
1171
1396
|
await state.scopeQueue;
|
|
1172
1397
|
const deletionKey = state.tombstones.get(sessionId)?.rootSessionId ?? sessionId;
|
|
@@ -1241,6 +1466,21 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1241
1466
|
}
|
|
1242
1467
|
});
|
|
1243
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
|
+
}
|
|
1244
1484
|
const childFirstDeletedSessions = [...deletedSessions].sort((left, right) =>
|
|
1245
1485
|
(right.depth ?? 0) - (left.depth ?? 0)
|
|
1246
1486
|
|| left.sessionId.localeCompare(right.sessionId));
|
|
@@ -1255,7 +1495,10 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1255
1495
|
}
|
|
1256
1496
|
}
|
|
1257
1497
|
try {
|
|
1258
|
-
const orphanErrors =
|
|
1498
|
+
const orphanErrors = [
|
|
1499
|
+
...slashCommandErrors,
|
|
1500
|
+
...await this.closeOrphanedResources(state.storageKey),
|
|
1501
|
+
];
|
|
1259
1502
|
if (orphanErrors.length > 0) throw combineErrors(orphanErrors);
|
|
1260
1503
|
for (const descendantRoot of descendantRoots) {
|
|
1261
1504
|
await this.finishTombstoneCleanup(
|
|
@@ -1282,7 +1525,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1282
1525
|
const stored = this.requireSession(state, sessionId);
|
|
1283
1526
|
await stored.projectionQueue;
|
|
1284
1527
|
this.assertStateAcceptingWork(state);
|
|
1285
|
-
return publicSnapshot(stored
|
|
1528
|
+
return publicSnapshot(this.visibleMessages(stored));
|
|
1286
1529
|
}
|
|
1287
1530
|
|
|
1288
1531
|
public async listMessagePage(
|
|
@@ -1312,20 +1555,21 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1312
1555
|
await stored.projectionQueue;
|
|
1313
1556
|
this.assertStateAcceptingWork(state);
|
|
1314
1557
|
const namespace = this.messageCursorNamespace(state.storageKey);
|
|
1315
|
-
|
|
1558
|
+
const visibleMessages = this.visibleMessages(stored);
|
|
1559
|
+
let end = visibleMessages.length;
|
|
1316
1560
|
if (options.before !== undefined) {
|
|
1317
1561
|
const cursor = this.parseMessageCursor(options.before);
|
|
1318
1562
|
if (cursor.namespace !== namespace || cursor.sessionId !== sessionId) {
|
|
1319
1563
|
throw new FlexHarnessValidationError('Message page cursor is invalid.');
|
|
1320
1564
|
}
|
|
1321
|
-
const anchor =
|
|
1565
|
+
const anchor = visibleMessages.findIndex((message) => message.messageId === cursor.anchorMessageId);
|
|
1322
1566
|
if (anchor < 0) throw new FlexHarnessValidationError('Message page cursor is stale.');
|
|
1323
1567
|
end = anchor;
|
|
1324
1568
|
}
|
|
1325
1569
|
let start = end;
|
|
1326
1570
|
let messages: IFlexMessage[] = [];
|
|
1327
1571
|
for (let index = end - 1; index >= 0 && messages.length < limit; index--) {
|
|
1328
|
-
const candidate = createBoundedTransferMessage(
|
|
1572
|
+
const candidate = createBoundedTransferMessage(visibleMessages[index]);
|
|
1329
1573
|
const candidateMessages = [candidate, ...messages];
|
|
1330
1574
|
const nextCursor = index > 0
|
|
1331
1575
|
? this.createMessageCursor(namespace, sessionId, candidate.messageId)
|
|
@@ -1357,110 +1601,638 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1357
1601
|
return publicSnapshot(createBoundedTransferMessage(this.requireMessage(stored, messageId)));
|
|
1358
1602
|
}
|
|
1359
1603
|
|
|
1360
|
-
public async
|
|
1361
|
-
scopeId: string,
|
|
1362
|
-
sessionId: string,
|
|
1363
|
-
prompt: TFlexPrompt,
|
|
1364
|
-
options: IFlexPromptOptions = {},
|
|
1365
|
-
): Promise<IFlexPromptResult> {
|
|
1366
|
-
const admission = await this.startPrompt(scopeId, sessionId, prompt, options);
|
|
1367
|
-
return admission.completion;
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
public async startPrompt(
|
|
1604
|
+
public async listSlashCommands(
|
|
1371
1605
|
scopeId: string,
|
|
1372
1606
|
sessionId: string,
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
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
|
+
});
|
|
1379
1631
|
}
|
|
1380
1632
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
):
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
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);
|
|
1390
1738
|
}
|
|
1391
1739
|
|
|
1392
|
-
public async
|
|
1740
|
+
public async executeSlashCommand(
|
|
1393
1741
|
scopeId: string,
|
|
1394
1742
|
sessionId: string,
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
const
|
|
1403
|
-
if (
|
|
1404
|
-
|
|
1405
|
-
|
|
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',
|
|
1406
1777
|
);
|
|
1407
1778
|
}
|
|
1408
|
-
|
|
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(
|
|
1409
1892
|
scopeId,
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1893
|
+
scope.scope,
|
|
1894
|
+
state,
|
|
1895
|
+
stored,
|
|
1896
|
+
parsed.name,
|
|
1897
|
+
parsed.rawArguments,
|
|
1898
|
+
parsed.arguments,
|
|
1899
|
+
registration!.handler!,
|
|
1900
|
+
options.signal,
|
|
1415
1901
|
);
|
|
1416
|
-
const admission = await queued.started;
|
|
1417
|
-
return Object.freeze({ ...admission, scheduleKey });
|
|
1418
1902
|
}
|
|
1419
1903
|
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
):
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
if (!entry) throw new FlexHarnessNotFoundError('Prompt queue entry', queueId);
|
|
1432
|
-
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'));
|
|
1433
1915
|
}
|
|
1434
1916
|
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
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;
|
|
1446
1943
|
}
|
|
1447
1944
|
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
):
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
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');
|
|
2226
|
+
requireTransferIdentifier(queueId, 'queueId');
|
|
2227
|
+
const { scope, state } = await this.resolveState(scopeId);
|
|
2228
|
+
const stored = this.requireSession(state, sessionId);
|
|
2229
|
+
const queued = stored.outstandingPromptsById.get(queueId);
|
|
2230
|
+
if (!queued) {
|
|
2231
|
+
if (stored.terminalPromptQueueEntries.has(queueId)) return false;
|
|
2232
|
+
throw new FlexHarnessNotFoundError('Prompt queue entry', queueId);
|
|
2233
|
+
}
|
|
2234
|
+
return this.cancelQueuedPrompt(
|
|
2235
|
+
queued,
|
|
1464
2236
|
this.trustInternalError(new FlexHarnessAbortError('The queued prompt was cancelled.')),
|
|
1465
2237
|
this.createCompactorContext(scopeId, scope.scope, state.storageKey, sessionId),
|
|
1466
2238
|
);
|
|
@@ -1618,86 +2390,606 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1618
2390
|
public async compactSession(scopeId: string, sessionId: string): Promise<void> {
|
|
1619
2391
|
const { scope, state } = await this.resolveState(scopeId);
|
|
1620
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
|
+
}
|
|
1621
2397
|
this.assertStateAcceptingWork(state);
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
2398
|
+
if (!this.agentSessionPolicy.contextCompactor) {
|
|
2399
|
+
throw new FlexHarnessSlashCommandUnavailableError(
|
|
2400
|
+
'compact',
|
|
2401
|
+
'No context compactor is configured.',
|
|
1626
2402
|
);
|
|
1627
|
-
} catch (error) {
|
|
1628
|
-
throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'compaction');
|
|
1629
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
|
+
);
|
|
1630
2415
|
}
|
|
1631
2416
|
|
|
1632
|
-
|
|
2417
|
+
private async compactStoredSession(
|
|
1633
2418
|
scopeId: string,
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
requireTransferIdentifier(compactionEventId, 'compactionEventId');
|
|
1640
|
-
}
|
|
1641
|
-
const { state } = await this.resolveState(scopeId);
|
|
1642
|
-
const stored = this.requireSession(state, sessionId);
|
|
1643
|
-
this.assertStateAcceptingWork(state);
|
|
2419
|
+
scope: TScope,
|
|
2420
|
+
state: IStorageState,
|
|
2421
|
+
stored: IStoredSessionState,
|
|
2422
|
+
signal?: AbortSignal,
|
|
2423
|
+
): Promise<void> {
|
|
1644
2424
|
try {
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
2425
|
+
await this.withCompactorContext(
|
|
2426
|
+
this.createCompactorContext(
|
|
2427
|
+
scopeId,
|
|
2428
|
+
scope,
|
|
2429
|
+
state.storageKey,
|
|
2430
|
+
stored.session.sessionId,
|
|
2431
|
+
),
|
|
2432
|
+
() => stored.agentSession.compact(signal === undefined ? {} : { abort: signal }),
|
|
2433
|
+
);
|
|
1653
2434
|
} catch (error) {
|
|
1654
|
-
throw this.projectOperationError(
|
|
2435
|
+
throw this.projectOperationError(
|
|
2436
|
+
error,
|
|
2437
|
+
'agentSession',
|
|
2438
|
+
scopeId,
|
|
2439
|
+
stored.session.sessionId,
|
|
2440
|
+
'compaction',
|
|
2441
|
+
);
|
|
1655
2442
|
}
|
|
1656
2443
|
}
|
|
1657
2444
|
|
|
1658
|
-
public async
|
|
2445
|
+
public async undoSession(
|
|
1659
2446
|
scopeId: string,
|
|
1660
2447
|
sessionId: string,
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
const
|
|
1664
|
-
|
|
1665
|
-
if (!stored.jobs) return [];
|
|
1666
|
-
try {
|
|
1667
|
-
const jobs = await stored.jobs.list();
|
|
1668
|
-
return publicSnapshot(jobs
|
|
1669
|
-
.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))
|
|
1670
|
-
.slice(0, maxBackgroundExecutions)
|
|
1671
|
-
.map((job) => this.projectBackgroundExecution(job)));
|
|
1672
|
-
} catch (error) {
|
|
1673
|
-
throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'background-list');
|
|
1674
|
-
}
|
|
2448
|
+
signal?: AbortSignal,
|
|
2449
|
+
): Promise<IFlexUndoSessionResult> {
|
|
2450
|
+
const result = await this.changeReversionCursor(scopeId, sessionId, 'undo', signal);
|
|
2451
|
+
return Object.freeze({ revertedRunId: result });
|
|
1675
2452
|
}
|
|
1676
2453
|
|
|
1677
|
-
public async
|
|
2454
|
+
public async redoSession(
|
|
1678
2455
|
scopeId: string,
|
|
1679
2456
|
sessionId: string,
|
|
1680
|
-
|
|
1681
|
-
): Promise<
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
const { state } = await this.resolveState(scopeId);
|
|
1685
|
-
const stored = this.requireSession(state, sessionId);
|
|
1686
|
-
this.assertStateAcceptingWork(state);
|
|
1687
|
-
try {
|
|
1688
|
-
return publicSnapshot(this.projectBackgroundExecution(
|
|
1689
|
-
await stored.agentSession.getBackgroundExecution(executionId),
|
|
1690
|
-
));
|
|
1691
|
-
} catch (error) {
|
|
1692
|
-
throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'background-get');
|
|
1693
|
-
}
|
|
2457
|
+
signal?: AbortSignal,
|
|
2458
|
+
): Promise<IFlexRedoSessionResult> {
|
|
2459
|
+
const result = await this.changeReversionCursor(scopeId, sessionId, 'redo', signal);
|
|
2460
|
+
return Object.freeze({ restoredRunId: result });
|
|
1694
2461
|
}
|
|
1695
2462
|
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
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));
|
|
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);
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
public async archiveSessionEvents(
|
|
2895
|
+
scopeId: string,
|
|
2896
|
+
sessionId: string,
|
|
2897
|
+
compactionEventId?: string,
|
|
2898
|
+
): Promise<IFlexEventArchiveMetadata | undefined> {
|
|
2899
|
+
if (compactionEventId !== undefined) {
|
|
2900
|
+
validateIdentifier(compactionEventId, 'compactionEventId');
|
|
2901
|
+
requireTransferIdentifier(compactionEventId, 'compactionEventId');
|
|
2902
|
+
}
|
|
2903
|
+
const { scope, state } = await this.resolveState(scopeId);
|
|
2904
|
+
const stored = this.requireSession(state, sessionId);
|
|
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;
|
|
2914
|
+
try {
|
|
2915
|
+
archive = await stored.agentSession.archiveCompactedEvents(compaction.id);
|
|
2916
|
+
} catch (error) {
|
|
2917
|
+
throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'archive');
|
|
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
|
+
});
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
public async listBackgroundExecutions(
|
|
2951
|
+
scopeId: string,
|
|
2952
|
+
sessionId: string,
|
|
2953
|
+
): Promise<IFlexBackgroundExecution[]> {
|
|
2954
|
+
const { state } = await this.resolveState(scopeId);
|
|
2955
|
+
const stored = this.requireSession(state, sessionId);
|
|
2956
|
+
this.assertStateAcceptingWork(state);
|
|
2957
|
+
if (!stored.jobs) return [];
|
|
2958
|
+
try {
|
|
2959
|
+
const jobs = await stored.jobs.list();
|
|
2960
|
+
return publicSnapshot(jobs
|
|
2961
|
+
.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0))
|
|
2962
|
+
.slice(0, maxBackgroundExecutions)
|
|
2963
|
+
.map((job) => this.projectBackgroundExecution(job)));
|
|
2964
|
+
} catch (error) {
|
|
2965
|
+
throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'background-list');
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
public async getBackgroundExecution(
|
|
2970
|
+
scopeId: string,
|
|
2971
|
+
sessionId: string,
|
|
2972
|
+
executionId: string,
|
|
2973
|
+
): Promise<IFlexBackgroundExecution> {
|
|
2974
|
+
validateIdentifier(executionId, 'executionId');
|
|
2975
|
+
requireTransferIdentifier(executionId, 'executionId');
|
|
2976
|
+
const { state } = await this.resolveState(scopeId);
|
|
2977
|
+
const stored = this.requireSession(state, sessionId);
|
|
2978
|
+
this.assertStateAcceptingWork(state);
|
|
2979
|
+
try {
|
|
2980
|
+
return publicSnapshot(this.projectBackgroundExecution(
|
|
2981
|
+
await stored.agentSession.getBackgroundExecution(executionId),
|
|
2982
|
+
));
|
|
2983
|
+
} catch (error) {
|
|
2984
|
+
throw this.projectOperationError(error, 'agentSession', scopeId, sessionId, 'background-get');
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
public async abortBackgroundExecution(
|
|
2989
|
+
scopeId: string,
|
|
2990
|
+
sessionId: string,
|
|
2991
|
+
executionId: string,
|
|
2992
|
+
): Promise<void> {
|
|
1701
2993
|
validateIdentifier(executionId, 'executionId');
|
|
1702
2994
|
requireTransferIdentifier(executionId, 'executionId');
|
|
1703
2995
|
const { scope, state } = await this.resolveState(scopeId);
|
|
@@ -1725,6 +3017,10 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1725
3017
|
}
|
|
1726
3018
|
|
|
1727
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
|
+
}
|
|
1728
3024
|
this.assertOpen();
|
|
1729
3025
|
validateIdentifier(scopeId, 'scopeId');
|
|
1730
3026
|
const existing = this.scopeRetirements.get(scopeId);
|
|
@@ -1744,6 +3040,12 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1744
3040
|
|
|
1745
3041
|
public async dispose(): Promise<void> {
|
|
1746
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
|
+
}
|
|
1747
3049
|
this.closed = true;
|
|
1748
3050
|
let disposal!: Promise<void>;
|
|
1749
3051
|
disposal = this.disposeInternal().catch((error) => {
|
|
@@ -1763,16 +3065,16 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1763
3065
|
debounceMs?: number,
|
|
1764
3066
|
subagentAdmission = false,
|
|
1765
3067
|
admissionSignal?: AbortSignal,
|
|
1766
|
-
|
|
3068
|
+
slashCommandAdmission = false,
|
|
3069
|
+
): Promise<{
|
|
3070
|
+
admission: IFlexPromptQueueAdmission;
|
|
3071
|
+
started: Promise<IFlexPromptAdmission>;
|
|
3072
|
+
queued?: IQueuedPrompt;
|
|
3073
|
+
}> {
|
|
1767
3074
|
this.assertOpen();
|
|
1768
3075
|
const normalizedPrompt = normalizeFlexPrompt(prompt);
|
|
1769
3076
|
const normalizedOptions = cloneSerializable(options);
|
|
1770
|
-
const byteSize =
|
|
1771
|
-
prompt: normalizedPrompt,
|
|
1772
|
-
options: normalizedOptions,
|
|
1773
|
-
scheduleKey,
|
|
1774
|
-
debounceMs,
|
|
1775
|
-
});
|
|
3077
|
+
const byteSize = this.promptAdmissionByteSize(prompt, options, scheduleKey, debounceMs);
|
|
1776
3078
|
const releasePendingAdmission = this.reservePendingPromptAdmission(byteSize);
|
|
1777
3079
|
let resolveSettled!: () => void;
|
|
1778
3080
|
const pendingOwner: IPendingPromptAdmission = {
|
|
@@ -1807,6 +3109,20 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1807
3109
|
}
|
|
1808
3110
|
this.assertStateAcceptingWork(state);
|
|
1809
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
|
+
}
|
|
1810
3126
|
if (stored.session.agent !== undefined && !subagentAdmission) {
|
|
1811
3127
|
throw new FlexHarnessValidationError(
|
|
1812
3128
|
'Subagent sessions can only be prompted through the foreground task tool.',
|
|
@@ -1875,6 +3191,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
1875
3191
|
return {
|
|
1876
3192
|
admission: Object.freeze({ queueId: queued.queueId, completion }),
|
|
1877
3193
|
started,
|
|
3194
|
+
queued,
|
|
1878
3195
|
};
|
|
1879
3196
|
} finally {
|
|
1880
3197
|
for (const signal of admissionSignals) {
|
|
@@ -2003,6 +3320,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2003
3320
|
const options = queued.options!;
|
|
2004
3321
|
let projectionReserved = false;
|
|
2005
3322
|
try {
|
|
3323
|
+
await this.commitRevertedBranch(run.state, run.stored, run.scopeId, run.scope as TScope);
|
|
2006
3324
|
run.transaction = await this.withRunCompactorContext(
|
|
2007
3325
|
run,
|
|
2008
3326
|
() => run.stored.agentSession.beginGeneration(
|
|
@@ -2016,6 +3334,29 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2016
3334
|
const reservation = this.createReservation(run, prompt);
|
|
2017
3335
|
await this.mutateProjection(run.state, run.stored, () => {
|
|
2018
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
|
+
}
|
|
2019
3360
|
});
|
|
2020
3361
|
projectionReserved = true;
|
|
2021
3362
|
run.reservedUserMessage = publicSnapshot(reservation.userMessage);
|
|
@@ -2236,6 +3577,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2236
3577
|
if (run.controller.signal.aborted) throw run.controller.signal.reason;
|
|
2237
3578
|
const result = normalizeRunResult(rawResult);
|
|
2238
3579
|
if (!run.modelResolution) throw new FlexHarnessValidationError('Generation completed without a resolved model.');
|
|
3580
|
+
await this.finalizeRunReversion(run, 'completed');
|
|
2239
3581
|
const terminal = this.buildTerminal(run, 'completed', result);
|
|
2240
3582
|
try {
|
|
2241
3583
|
await this.mutateProjection(run.state, run.stored, () => {
|
|
@@ -2266,6 +3608,13 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2266
3608
|
}
|
|
2267
3609
|
this.emitTerminalProjection(run, terminal);
|
|
2268
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
|
+
);
|
|
2269
3618
|
return {
|
|
2270
3619
|
runId: run.runId,
|
|
2271
3620
|
sessionId: run.sessionId,
|
|
@@ -2306,6 +3655,14 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2306
3655
|
? run.ownerCancellation!
|
|
2307
3656
|
: this.projectExternalError(run, error, generated ? 'persistence' : 'agentSession');
|
|
2308
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
|
+
}
|
|
2309
3666
|
if (generated && !this.isTombstoned(run)) {
|
|
2310
3667
|
const failedStage = this.buildTerminal(run, cancelled ? 'cancelled' : 'failed', undefined, safeError);
|
|
2311
3668
|
try {
|
|
@@ -2360,6 +3717,13 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2360
3717
|
this.emitTerminalProjection(run, terminal);
|
|
2361
3718
|
this.emitFinalRunEvent(run, terminal.assistantMessage, terminalError, publicStatus === 'cancelled');
|
|
2362
3719
|
}
|
|
3720
|
+
await this.drainReversionReleases(
|
|
3721
|
+
run.state,
|
|
3722
|
+
run.stored,
|
|
3723
|
+
run.scopeId,
|
|
3724
|
+
run.scope as TScope,
|
|
3725
|
+
false,
|
|
3726
|
+
);
|
|
2363
3727
|
throw errors.length > 1 ? combineErrors(errors) : terminalError;
|
|
2364
3728
|
}
|
|
2365
3729
|
|
|
@@ -2369,6 +3733,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2369
3733
|
signal: AbortSignal,
|
|
2370
3734
|
): Promise<plugins.IAgentGenerationLease> {
|
|
2371
3735
|
signal.throwIfAborted();
|
|
3736
|
+
await this.prepareRunReversion(run, signal);
|
|
2372
3737
|
run.phase = 'running';
|
|
2373
3738
|
const queued = run.stored.outstandingPromptsById.get(run.queueId);
|
|
2374
3739
|
if (queued && (queued.status === 'starting' || queued.status === 'scheduled')) {
|
|
@@ -2991,84 +4356,826 @@ export class FlexHarness<TScope = unknown> {
|
|
|
2991
4356
|
return { userMessage, assistantMessage };
|
|
2992
4357
|
}
|
|
2993
4358
|
|
|
2994
|
-
private buildTerminal(
|
|
2995
|
-
run: IActiveRun,
|
|
2996
|
-
status: 'completed' | 'failed' | 'cancelled',
|
|
2997
|
-
result?: IRunResultProjection,
|
|
2998
|
-
error?: Error,
|
|
2999
|
-
): IFlexTerminalProjection {
|
|
3000
|
-
const timestamp = new Date().toISOString();
|
|
3001
|
-
const userMessage: IFlexMessage = cloneSerializable(
|
|
3002
|
-
run.reservedUserMessage
|
|
3003
|
-
?? run.stored.messages.find((message) => message.messageId === run.userMessageId)
|
|
3004
|
-
?? {
|
|
3005
|
-
messageId: run.userMessageId,
|
|
3006
|
-
sessionId: run.sessionId,
|
|
3007
|
-
runId: run.runId,
|
|
3008
|
-
role: 'user',
|
|
3009
|
-
status: 'streaming',
|
|
3010
|
-
createdAt: timestamp,
|
|
3011
|
-
parts: [],
|
|
3012
|
-
} satisfies IFlexMessage,
|
|
3013
|
-
);
|
|
3014
|
-
const assistantMessage: IFlexMessage = cloneSerializable(
|
|
3015
|
-
run.reservedAssistantMessage
|
|
3016
|
-
?? run.stored.messages.find((message) => message.messageId === run.assistantMessageId)
|
|
3017
|
-
?? {
|
|
3018
|
-
messageId: run.assistantMessageId,
|
|
3019
|
-
sessionId: run.sessionId,
|
|
3020
|
-
runId: run.runId,
|
|
3021
|
-
role: 'assistant',
|
|
3022
|
-
status: 'streaming',
|
|
3023
|
-
createdAt: timestamp,
|
|
3024
|
-
parts: [],
|
|
3025
|
-
} satisfies IFlexMessage,
|
|
3026
|
-
);
|
|
3027
|
-
assistantMessage.parts = cloneSerializable(run.callbackParts);
|
|
3028
|
-
const terminalParts = assistantMessage.parts;
|
|
3029
|
-
for (const part of terminalParts) {
|
|
3030
|
-
if (part.type === 'reasoning' && part.status === 'running') {
|
|
3031
|
-
part.status = status === 'completed' ? 'completed' : 'cancelled';
|
|
3032
|
-
} else if (part.type === 'tool' && part.status === 'running') {
|
|
3033
|
-
part.status = status === 'cancelled' ? 'cancelled' : 'failed';
|
|
3034
|
-
part.error = status === 'cancelled'
|
|
3035
|
-
? 'Tool execution was cancelled.'
|
|
3036
|
-
: 'Tool execution ended without a terminal event.';
|
|
4359
|
+
private buildTerminal(
|
|
4360
|
+
run: IActiveRun,
|
|
4361
|
+
status: 'completed' | 'failed' | 'cancelled',
|
|
4362
|
+
result?: IRunResultProjection,
|
|
4363
|
+
error?: Error,
|
|
4364
|
+
): IFlexTerminalProjection {
|
|
4365
|
+
const timestamp = new Date().toISOString();
|
|
4366
|
+
const userMessage: IFlexMessage = cloneSerializable(
|
|
4367
|
+
run.reservedUserMessage
|
|
4368
|
+
?? run.stored.messages.find((message) => message.messageId === run.userMessageId)
|
|
4369
|
+
?? {
|
|
4370
|
+
messageId: run.userMessageId,
|
|
4371
|
+
sessionId: run.sessionId,
|
|
4372
|
+
runId: run.runId,
|
|
4373
|
+
role: 'user',
|
|
4374
|
+
status: 'streaming',
|
|
4375
|
+
createdAt: timestamp,
|
|
4376
|
+
parts: [],
|
|
4377
|
+
} satisfies IFlexMessage,
|
|
4378
|
+
);
|
|
4379
|
+
const assistantMessage: IFlexMessage = cloneSerializable(
|
|
4380
|
+
run.reservedAssistantMessage
|
|
4381
|
+
?? run.stored.messages.find((message) => message.messageId === run.assistantMessageId)
|
|
4382
|
+
?? {
|
|
4383
|
+
messageId: run.assistantMessageId,
|
|
4384
|
+
sessionId: run.sessionId,
|
|
4385
|
+
runId: run.runId,
|
|
4386
|
+
role: 'assistant',
|
|
4387
|
+
status: 'streaming',
|
|
4388
|
+
createdAt: timestamp,
|
|
4389
|
+
parts: [],
|
|
4390
|
+
} satisfies IFlexMessage,
|
|
4391
|
+
);
|
|
4392
|
+
assistantMessage.parts = cloneSerializable(run.callbackParts);
|
|
4393
|
+
const terminalParts = assistantMessage.parts;
|
|
4394
|
+
for (const part of terminalParts) {
|
|
4395
|
+
if (part.type === 'reasoning' && part.status === 'running') {
|
|
4396
|
+
part.status = status === 'completed' ? 'completed' : 'cancelled';
|
|
4397
|
+
} else if (part.type === 'tool' && part.status === 'running') {
|
|
4398
|
+
part.status = status === 'cancelled' ? 'cancelled' : 'failed';
|
|
4399
|
+
part.error = status === 'cancelled'
|
|
4400
|
+
? 'Tool execution was cancelled.'
|
|
4401
|
+
: 'Tool execution ended without a terminal event.';
|
|
4402
|
+
}
|
|
4403
|
+
}
|
|
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
|
+
);
|
|
3037
5027
|
}
|
|
5028
|
+
return;
|
|
3038
5029
|
}
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
text: truncateUtf8(result.text, this.callbackLimits.maxOutputBytes),
|
|
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),
|
|
3050
5040
|
});
|
|
3051
5041
|
}
|
|
3052
|
-
assistantMessage.usage = cloneSerializable(result.usage);
|
|
3053
|
-
} else {
|
|
3054
|
-
const message = truncateUtf8(error?.message ?? externalErrorFallback.message, maxTransferMetadataBytes);
|
|
3055
|
-
userMessage.error = message;
|
|
3056
|
-
assistantMessage.error = message;
|
|
3057
5042
|
}
|
|
3058
|
-
|
|
3059
|
-
runId:
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
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
|
+
);
|
|
3069
5119
|
}
|
|
3070
|
-
|
|
3071
|
-
|
|
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
|
+
}
|
|
3072
5179
|
}
|
|
3073
5180
|
|
|
3074
5181
|
private async promoteCompletedTerminal(
|
|
@@ -3688,6 +5795,13 @@ export class FlexHarness<TScope = unknown> {
|
|
|
3688
5795
|
await this.mutateProjection(run.state, run.stored, () => {
|
|
3689
5796
|
run.stored.messages = run.stored.messages.filter((message) => message.runId !== run.runId);
|
|
3690
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;
|
|
3691
5805
|
}, true);
|
|
3692
5806
|
} catch (error) {
|
|
3693
5807
|
errors.push(this.projectExternalError(run, error, 'persistence'));
|
|
@@ -4060,6 +6174,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4060
6174
|
storageKey,
|
|
4061
6175
|
compactorLifecycleController,
|
|
4062
6176
|
scopeIdHint: scopeId,
|
|
6177
|
+
scopeContext: { scopeId, scope },
|
|
4063
6178
|
revision: snapshot.revision,
|
|
4064
6179
|
sessions: new Map(),
|
|
4065
6180
|
retainedSessionCleanups: new Map(),
|
|
@@ -4084,7 +6199,14 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4084
6199
|
const stored = await this.loadSessionRuntime(state, metadata, scopeId, scope);
|
|
4085
6200
|
state.sessions.set(metadata.sessionId, stored);
|
|
4086
6201
|
loadedSessions.push(stored);
|
|
4087
|
-
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);
|
|
4088
6210
|
if (stored.projectionRevision < 0) throw new Error('Invalid projection revision.');
|
|
4089
6211
|
}
|
|
4090
6212
|
for (const stored of state.sessions.values()) {
|
|
@@ -4115,6 +6237,13 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4115
6237
|
try {
|
|
4116
6238
|
const group = this.tombstoneGroup(state, rootSessionId);
|
|
4117
6239
|
for (const tombstone of group) {
|
|
6240
|
+
await this.releaseDeletedSessionReversions(
|
|
6241
|
+
state,
|
|
6242
|
+
storageKey,
|
|
6243
|
+
tombstone.sessionId,
|
|
6244
|
+
scopeId,
|
|
6245
|
+
scope,
|
|
6246
|
+
);
|
|
4118
6247
|
await this.cleanupSessionDomains(storageKey, tombstone.sessionId);
|
|
4119
6248
|
}
|
|
4120
6249
|
for (const tombstone of group) state.tombstones.delete(tombstone.sessionId);
|
|
@@ -4130,6 +6259,16 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4130
6259
|
}
|
|
4131
6260
|
return state;
|
|
4132
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
|
+
}
|
|
4133
6272
|
const closeResults = await Promise.allSettled(
|
|
4134
6273
|
loadedSessions.map((stored) => this.closeStoredSession(stored)),
|
|
4135
6274
|
);
|
|
@@ -4162,6 +6301,12 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4162
6301
|
session,
|
|
4163
6302
|
messages: [],
|
|
4164
6303
|
stagedTerminals: [],
|
|
6304
|
+
reversionSegments: [],
|
|
6305
|
+
revertCursor: 0,
|
|
6306
|
+
excludedRunIds: [],
|
|
6307
|
+
pendingReversionReleases: [],
|
|
6308
|
+
projectionSchemaVersion: 2,
|
|
6309
|
+
projectionReconciliationRequired: false,
|
|
4165
6310
|
projectionRevision: 0,
|
|
4166
6311
|
projectionQueue: Promise.resolve(),
|
|
4167
6312
|
rememberedPermissionKeys: new Set(),
|
|
@@ -4228,6 +6373,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4228
6373
|
throw combineErrors(acquisitionErrors);
|
|
4229
6374
|
}
|
|
4230
6375
|
const projection = projectionResult.value;
|
|
6376
|
+
const projectionV2 = projection?.schemaVersion === 2 ? projection : undefined;
|
|
4231
6377
|
const permission = permissionResult.value;
|
|
4232
6378
|
const eventStore = eventStoreResult.value;
|
|
4233
6379
|
const jobStore = jobStoreResult.value;
|
|
@@ -4258,6 +6404,20 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4258
6404
|
session: cloneSerializable(metadata),
|
|
4259
6405
|
messages: cloneSerializable(projection?.messages ?? []),
|
|
4260
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
|
+
: {}),
|
|
4261
6421
|
projectionRevision: projection?.revision ?? 0,
|
|
4262
6422
|
projectionQueue: Promise.resolve(),
|
|
4263
6423
|
rememberedPermissionKeys: new Set(permission?.rememberedPermissionKeys ?? []),
|
|
@@ -4306,7 +6466,9 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4306
6466
|
eventStore,
|
|
4307
6467
|
executionContext: contextualExecutionContext,
|
|
4308
6468
|
contextBuilder: ({ events }) => hydrateAgentMessages(
|
|
4309
|
-
(this.agentSessionPolicy.contextBuilder ?? ((options) => plugins.buildModelMessages(options.events)))({
|
|
6469
|
+
(this.agentSessionPolicy.contextBuilder ?? ((options) => plugins.buildModelMessages(options.events)))({
|
|
6470
|
+
events: this.filterReversionContextEvents(stored, events),
|
|
6471
|
+
}),
|
|
4310
6472
|
),
|
|
4311
6473
|
...(contextCompactor
|
|
4312
6474
|
? {
|
|
@@ -4323,7 +6485,14 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4323
6485
|
) {
|
|
4324
6486
|
throw new Error('Agent context compaction is missing its exact FlexHarness invocation context.');
|
|
4325
6487
|
}
|
|
4326
|
-
|
|
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, {
|
|
4327
6496
|
...options,
|
|
4328
6497
|
...invocationContext,
|
|
4329
6498
|
scope: invocationContext.scope as TScope,
|
|
@@ -4390,13 +6559,37 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4390
6559
|
private async repairLoadedSession(
|
|
4391
6560
|
storageKey: string,
|
|
4392
6561
|
stored: IStoredSessionState,
|
|
6562
|
+
state?: IStorageState,
|
|
6563
|
+
scopeId?: string,
|
|
6564
|
+
scope?: TScope,
|
|
4393
6565
|
): Promise<boolean> {
|
|
4394
6566
|
const beforeProjection = JSON.stringify({
|
|
4395
6567
|
messages: stored.messages,
|
|
4396
6568
|
stagedTerminals: stored.stagedTerminals,
|
|
6569
|
+
reversionSegments: stored.reversionSegments,
|
|
6570
|
+
revertCursor: stored.revertCursor,
|
|
6571
|
+
excludedRunIds: stored.excludedRunIds,
|
|
6572
|
+
pendingReversion: stored.pendingReversion,
|
|
6573
|
+
pendingReversionReleases: stored.pendingReversionReleases,
|
|
4397
6574
|
});
|
|
4398
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
|
+
}
|
|
4399
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;
|
|
4400
6593
|
const stages = new Map(stored.stagedTerminals.map((terminal) => [terminal.runId, terminal]));
|
|
4401
6594
|
const runIds = new Set([
|
|
4402
6595
|
...stored.messages.map((message) => message.runId),
|
|
@@ -4433,6 +6626,37 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4433
6626
|
}
|
|
4434
6627
|
stored.stagedTerminals = stored.stagedTerminals.filter((terminal) => !outcomes.has(terminal.runId));
|
|
4435
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
|
+
}
|
|
4436
6660
|
if (latestTerminal) {
|
|
4437
6661
|
const completedAt = latestTerminal.assistantMessage.completedAt ?? new Date().toISOString();
|
|
4438
6662
|
stored.session.status = latestTerminal.status === 'completed' ? 'idle' : latestTerminal.status;
|
|
@@ -4459,6 +6683,11 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4459
6683
|
const projectionChanged = beforeProjection !== JSON.stringify({
|
|
4460
6684
|
messages: stored.messages,
|
|
4461
6685
|
stagedTerminals: stored.stagedTerminals,
|
|
6686
|
+
reversionSegments: stored.reversionSegments,
|
|
6687
|
+
revertCursor: stored.revertCursor,
|
|
6688
|
+
excludedRunIds: stored.excludedRunIds,
|
|
6689
|
+
pendingReversion: stored.pendingReversion,
|
|
6690
|
+
pendingReversionReleases: stored.pendingReversionReleases,
|
|
4462
6691
|
});
|
|
4463
6692
|
if (projectionChanged) {
|
|
4464
6693
|
const expected = stored.projectionRevision;
|
|
@@ -4469,6 +6698,9 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4469
6698
|
snapshot,
|
|
4470
6699
|
expected,
|
|
4471
6700
|
);
|
|
6701
|
+
stored.projectionSchemaVersion = 2;
|
|
6702
|
+
delete stored.projectionBaseline;
|
|
6703
|
+
stored.projectionReconciliationRequired = false;
|
|
4472
6704
|
stored.projectionRevision = snapshot.revision;
|
|
4473
6705
|
}
|
|
4474
6706
|
return beforeSession !== JSON.stringify(stored.session);
|
|
@@ -4628,18 +6860,47 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4628
6860
|
): Promise<TValue> {
|
|
4629
6861
|
const operation = stored.projectionQueue.then(async () => {
|
|
4630
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);
|
|
4631
6868
|
const beforeMessages = cloneSerializable(stored.messages);
|
|
4632
6869
|
const beforeStages = cloneSerializable(stored.stagedTerminals);
|
|
4633
|
-
const
|
|
4634
|
-
|
|
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,
|
|
4635
6879
|
revision: beforeRevision,
|
|
4636
6880
|
messages: cloneSerializable(beforeMessages),
|
|
4637
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),
|
|
4638
6889
|
};
|
|
4639
6890
|
const restore = () => {
|
|
6891
|
+
stored.projectionSchemaVersion = beforeSchemaVersion;
|
|
6892
|
+
stored.projectionReconciliationRequired = beforeReconciliationRequired;
|
|
6893
|
+
if (beforeBaseline === undefined) delete stored.projectionBaseline;
|
|
6894
|
+
else stored.projectionBaseline = beforeBaseline;
|
|
4640
6895
|
stored.projectionRevision = beforeRevision;
|
|
4641
6896
|
stored.messages = beforeMessages;
|
|
4642
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;
|
|
4643
6904
|
};
|
|
4644
6905
|
let result: TValue;
|
|
4645
6906
|
try {
|
|
@@ -4656,6 +6917,9 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4656
6917
|
snapshot,
|
|
4657
6918
|
beforeRevision,
|
|
4658
6919
|
);
|
|
6920
|
+
stored.projectionSchemaVersion = 2;
|
|
6921
|
+
delete stored.projectionBaseline;
|
|
6922
|
+
stored.projectionReconciliationRequired = false;
|
|
4659
6923
|
stored.projectionRevision = snapshot.revision;
|
|
4660
6924
|
return result;
|
|
4661
6925
|
} catch (error) {
|
|
@@ -4664,11 +6928,13 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4664
6928
|
throw error;
|
|
4665
6929
|
}
|
|
4666
6930
|
if (error instanceof FlexHarnessStoreCommitUncertainError) {
|
|
6931
|
+
stored.projectionSchemaVersion = 2;
|
|
6932
|
+
stored.projectionReconciliationRequired = true;
|
|
4667
6933
|
stored.projectionRevision = snapshot.revision;
|
|
4668
6934
|
state.lifecycle = 'fenced';
|
|
4669
6935
|
throw error;
|
|
4670
6936
|
}
|
|
4671
|
-
let current:
|
|
6937
|
+
let current: TFlexProjectionSnapshot | undefined;
|
|
4672
6938
|
let reconciliationError: unknown;
|
|
4673
6939
|
try {
|
|
4674
6940
|
current = await this.stores.projections.load(
|
|
@@ -4679,6 +6945,9 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4679
6945
|
reconciliationError = loadError;
|
|
4680
6946
|
}
|
|
4681
6947
|
if (current && JSON.stringify(current) === JSON.stringify(snapshot)) {
|
|
6948
|
+
stored.projectionSchemaVersion = 2;
|
|
6949
|
+
delete stored.projectionBaseline;
|
|
6950
|
+
stored.projectionReconciliationRequired = false;
|
|
4682
6951
|
stored.projectionRevision = snapshot.revision;
|
|
4683
6952
|
throw error;
|
|
4684
6953
|
}
|
|
@@ -4689,6 +6958,8 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4689
6958
|
restore();
|
|
4690
6959
|
throw error;
|
|
4691
6960
|
}
|
|
6961
|
+
stored.projectionSchemaVersion = 2;
|
|
6962
|
+
stored.projectionReconciliationRequired = true;
|
|
4692
6963
|
stored.projectionRevision = snapshot.revision;
|
|
4693
6964
|
state.lifecycle = 'fenced';
|
|
4694
6965
|
throw reconciliationError === undefined
|
|
@@ -4797,15 +7068,54 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4797
7068
|
private createProjectionSnapshot(
|
|
4798
7069
|
stored: IStoredSessionState,
|
|
4799
7070
|
revision: number,
|
|
4800
|
-
):
|
|
7071
|
+
): IFlexProjectionSnapshotCurrent {
|
|
4801
7072
|
return {
|
|
4802
|
-
schemaVersion:
|
|
7073
|
+
schemaVersion: 2,
|
|
4803
7074
|
revision,
|
|
4804
7075
|
messages: cloneSerializable(stored.messages),
|
|
4805
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),
|
|
4806
7084
|
};
|
|
4807
7085
|
}
|
|
4808
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
|
+
|
|
4809
7119
|
private async cleanupSessionDomains(storageKey: string, sessionId: string): Promise<void> {
|
|
4810
7120
|
const results = await Promise.allSettled([
|
|
4811
7121
|
this.stores.agentEvents.deleteSession(storageKey, sessionId),
|
|
@@ -4914,15 +7224,19 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4914
7224
|
}
|
|
4915
7225
|
const group = this.tombstoneGroup(state, rootSessionId);
|
|
4916
7226
|
if (group.length === 0) return;
|
|
7227
|
+
const exactInvocation = invocation ?? {
|
|
7228
|
+
scopeId: state.scopeContext.scopeId,
|
|
7229
|
+
scope: state.scopeContext.scope as TScope,
|
|
7230
|
+
};
|
|
4917
7231
|
const groupIds = new Set(group.map((tombstone) => tombstone.sessionId));
|
|
4918
7232
|
const reason = this.trustInternalError(new FlexHarnessAbortError('The session subtree was deleted.'));
|
|
4919
7233
|
const contextFor = (
|
|
4920
7234
|
sessionId: string,
|
|
4921
7235
|
retained?: IRetainedSessionCleanup,
|
|
4922
|
-
): IFlexAgentContextInvocation<TScope> | undefined =>
|
|
7236
|
+
): IFlexAgentContextInvocation<TScope> | undefined => exactInvocation
|
|
4923
7237
|
? this.createCompactorContext(
|
|
4924
|
-
|
|
4925
|
-
|
|
7238
|
+
exactInvocation.scopeId,
|
|
7239
|
+
exactInvocation.scope,
|
|
4926
7240
|
state.storageKey,
|
|
4927
7241
|
sessionId,
|
|
4928
7242
|
)
|
|
@@ -4995,6 +7309,21 @@ export class FlexHarness<TScope = unknown> {
|
|
|
4995
7309
|
if (this.hasOrphanedSessionResources(state.storageKey, sessionId)) {
|
|
4996
7310
|
throw new Error(`Session "${sessionId}" still has runtime cleanup ownership.`);
|
|
4997
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
|
+
);
|
|
4998
7327
|
await this.cleanupSessionDomains(state.storageKey, sessionId);
|
|
4999
7328
|
if (retained) retained.domainsCompleted = true;
|
|
5000
7329
|
}
|
|
@@ -5182,6 +7511,26 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5182
7511
|
void drain.catch(() => undefined);
|
|
5183
7512
|
}
|
|
5184
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
|
+
|
|
5185
7534
|
private sessionsAreDependencyRelated(
|
|
5186
7535
|
state: IStorageState,
|
|
5187
7536
|
leftSessionId: string,
|
|
@@ -5387,6 +7736,20 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5387
7736
|
|
|
5388
7737
|
private async closeOrphanedResourcesInternal(storageKey?: string): Promise<unknown[]> {
|
|
5389
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
|
+
}
|
|
5390
7753
|
const tombstoneCleanups = [...this.orphanedTombstoneCleanups.values()]
|
|
5391
7754
|
.filter((retained) => storageKey === undefined || retained.storageKey === storageKey);
|
|
5392
7755
|
const tombstoneResults = await Promise.allSettled(
|
|
@@ -5436,6 +7799,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5436
7799
|
...[...this.orphanedExecutionContexts.values()].map((owner) => owner.storageKey),
|
|
5437
7800
|
...[...this.orphanedProviderReleases.values()].map((retained) => retained.storageKey),
|
|
5438
7801
|
...[...this.orphanedTombstoneCleanups.values()].map((retained) => retained.storageKey),
|
|
7802
|
+
...[...this.orphanedTombstoneOwners.values()].map((owner) => owner.state.storageKey),
|
|
5439
7803
|
]);
|
|
5440
7804
|
}
|
|
5441
7805
|
|
|
@@ -5443,7 +7807,23 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5443
7807
|
const scope = await this.scopeResolver.resolveScope(scopeId);
|
|
5444
7808
|
this.assertOpen();
|
|
5445
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
|
+
}
|
|
5446
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
|
+
);
|
|
5447
7827
|
const stateLoad = this.stateLoads.get(scope.storageKey);
|
|
5448
7828
|
if (!stateLoad) {
|
|
5449
7829
|
const drain = this.storageDrains.get(scope.storageKey);
|
|
@@ -5585,6 +7965,21 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5585
7965
|
'lifecycle-close',
|
|
5586
7966
|
));
|
|
5587
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
|
+
}
|
|
5588
7983
|
this.purgeStoredPromptQueue(stored);
|
|
5589
7984
|
}
|
|
5590
7985
|
const roots = this.orderTombstoneRootsChildFirst(
|
|
@@ -5629,6 +8024,21 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5629
8024
|
if (!pending.controller.signal.aborted) pending.controller.abort(pendingAdmissionReason);
|
|
5630
8025
|
}
|
|
5631
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
|
+
);
|
|
5632
8042
|
const loads = [...this.stateLoads.entries()];
|
|
5633
8043
|
const results = await Promise.allSettled(loads.map(([storageKey, stateLoad]) =>
|
|
5634
8044
|
this.drainStorage(
|
|
@@ -5638,7 +8048,6 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5638
8048
|
'The run was aborted because FlexHarness was disposed.',
|
|
5639
8049
|
)),
|
|
5640
8050
|
)));
|
|
5641
|
-
const errors: unknown[] = [];
|
|
5642
8051
|
for (const result of results) {
|
|
5643
8052
|
if (result.status === 'rejected') this.appendUnexpectedErrors(errors, result.reason);
|
|
5644
8053
|
}
|
|
@@ -5651,6 +8060,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5651
8060
|
this.listeners.clear();
|
|
5652
8061
|
if (errors.length > 0) throw combineErrors(errors);
|
|
5653
8062
|
this.compactorInvocationContext.disable();
|
|
8063
|
+
this.slashCommandInvocationContext.disable();
|
|
5654
8064
|
this.stateLoads.clear();
|
|
5655
8065
|
this.scopeAdmissions.clear();
|
|
5656
8066
|
this.scopeRetirements.clear();
|
|
@@ -5658,6 +8068,45 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5658
8068
|
this.storageCompactorLifecycleControllers.clear();
|
|
5659
8069
|
}
|
|
5660
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
|
+
|
|
5661
8110
|
private appendUnexpectedErrors(target: unknown[], error: unknown): void {
|
|
5662
8111
|
if (error instanceof FlexHarnessRunError) {
|
|
5663
8112
|
for (const nested of error.errors) this.appendUnexpectedErrors(target, nested);
|
|
@@ -5805,6 +8254,18 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5805
8254
|
return stored;
|
|
5806
8255
|
}
|
|
5807
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
|
+
|
|
5808
8269
|
private requireMutableSession(state: IStorageState, sessionId: string): IStoredSessionState {
|
|
5809
8270
|
const stored = this.requireSession(state, sessionId);
|
|
5810
8271
|
const pending = [...state.pendingPermissions.values()].some((entry) =>
|
|
@@ -5819,7 +8280,7 @@ export class FlexHarness<TScope = unknown> {
|
|
|
5819
8280
|
}
|
|
5820
8281
|
|
|
5821
8282
|
private requireMessage(stored: IStoredSessionState, messageId: string): IFlexMessage {
|
|
5822
|
-
const message = stored.
|
|
8283
|
+
const message = this.visibleMessages(stored).find((entry) => entry.messageId === messageId);
|
|
5823
8284
|
if (!message) throw new FlexHarnessNotFoundError('Message', messageId);
|
|
5824
8285
|
return message;
|
|
5825
8286
|
}
|