@llblab/pi-kit 0.16.0 → 0.17.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 +6 -0
- package/README.md +1 -1
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +4 -4
- package/node_modules/@llblab/pi-state-flow/BACKLOG.md +3 -1
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +16 -0
- package/node_modules/@llblab/pi-state-flow/README.md +5 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +1 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +7 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/extension.d.ts +2 -4
- package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +31 -239
- package/node_modules/@llblab/pi-state-flow/dist/lib/history.js +1 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/logging.d.ts +18 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/logging.js +44 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/migration.d.ts +2 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/migration.js +21 -3
- package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.d.ts +7 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +55 -5
- package/node_modules/@llblab/pi-state-flow/dist/lib/publication.d.ts +17 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/publication.js +102 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/query.d.ts +16 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/query.js +98 -12
- package/node_modules/@llblab/pi-state-flow/dist/lib/session.d.ts +17 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/session.js +38 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/state.d.ts +5 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/state.js +10 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +1 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +1 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +4 -3
- package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
- package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +79 -0
- package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +23 -129
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +22 -17
- package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +1 -1
- package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +15 -6
- package/node_modules/@llblab/pi-state-flow/docs/usage.md +2 -2
- package/node_modules/@llblab/pi-state-flow/lib/durable.ts +6 -1
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +33 -228
- package/node_modules/@llblab/pi-state-flow/lib/history.ts +1 -1
- package/node_modules/@llblab/pi-state-flow/lib/logging.ts +53 -1
- package/node_modules/@llblab/pi-state-flow/lib/migration.ts +18 -4
- package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +51 -5
- package/node_modules/@llblab/pi-state-flow/lib/publication.ts +96 -0
- package/node_modules/@llblab/pi-state-flow/lib/query.ts +99 -11
- package/node_modules/@llblab/pi-state-flow/lib/session.ts +45 -0
- package/node_modules/@llblab/pi-state-flow/lib/state.ts +13 -2
- package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +2 -1
- package/node_modules/@llblab/pi-state-flow/lib/transition.ts +4 -3
- package/node_modules/@llblab/pi-state-flow/package.json +1 -1
- package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +79 -0
- package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +23 -129
- package/package.json +2 -2
|
@@ -20,7 +20,7 @@ State Flow does not undo tool effects. After interruption or returning to an old
|
|
|
20
20
|
|
|
21
21
|
Native fork replacement copies the source session checkpoint, retained patch tail and matching provenance into the new session's own storage. Global/CWD streams and provenance stay current and unchanged. An earlier fork selection copies that point's private state, not the parent's later private work. Parent data/history remain intact; selected enablement is retained, so a stopped source does not become enabled automatically.
|
|
22
22
|
|
|
23
|
-
The child starts at step zero and a new temporal origin. Its copied tail is preserved, but pre-origin records are not seven past aligned `
|
|
23
|
+
The child starts at step zero and a new temporal origin. Its copied tail is preserved, but pre-origin records are not seven past aligned causal boundaries addressable through `effective[n]` or scoped paths. The child's own transitions build its hot window; owned checkpoints support normal reload/resume. Parent Stop projection is not inherited, including after child reload.
|
|
24
24
|
|
|
25
25
|
Initial copying requires a native fork start event, a regular canonical direct-parent session file, matching CWD/identity, a readable temporal source and an unused child namespace. Missing/unsafe evidence or CAS conflicts leave the copy unavailable rather than importing unrelated or newer private state. Explicit Start can retry an unaccepted copy in the same loaded fork after the cause is corrected.
|
|
26
26
|
|
|
@@ -144,4 +144,4 @@ The agent should use sufficient materialized knowledge before rereading files. R
|
|
|
144
144
|
|
|
145
145
|
Knowledge discovery finds regular lowercase `*.md` beneath its configured root, hashes opaque bytes, and skips symlinks. Only confirmed missing Markdown paths within an available root are pruned; external/non-Markdown artifacts and state under a missing whole root are preserved. An unavailable root makes freshness unknown in status. Successful reads of stale ordinary candidates require same-path global compilation; Skill reads require CWD compilation. The runtime owns provenance: model patches cannot write or delete individual freshness fields, including legacy spellings. Existing legacy entries remain readable and semantically editable. Missing freshness evidence means unknown-but-usable, not proof that a source was acquired.
|
|
146
146
|
|
|
147
|
-
The
|
|
147
|
+
The packaged `state-flow-guide` Skill answers concrete operational questions about reads, patches, inheritance, acquisition, finalization, and recovery without initiating cleanup. The separate `state-flow-memory` Skill handles explicit bounded curation, feature/release/project phase-boundary reconciliation, and external promotion. Ordinary handoffs clean only touched and obviously stale visible branches; neither Skill is a background maintenance loop. Promotion must verify the destination before removing the only accepted source copy. Artifact/compiler details and model-tool contracts belong in the [architecture](architecture.md#artifact-routing).
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from "./artifact.ts";
|
|
21
21
|
import { canonicalJson, isJsonValue, isObject } from "./json.ts";
|
|
22
22
|
import { validateScopeStream, validateTemporalState, type ScopeStream, type TemporalState } from "./temporal.ts";
|
|
23
|
-
import type
|
|
23
|
+
import { migratePreIntentState, type StateScope } from "./state.ts";
|
|
24
24
|
|
|
25
25
|
const SESSION_KEY_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
26
26
|
const STATE_FILE = "state.json";
|
|
@@ -100,6 +100,7 @@ export function classifyScopeStream(
|
|
|
100
100
|
}
|
|
101
101
|
const temporal = meta?.temporal;
|
|
102
102
|
if (isObject(temporal) && Object.hasOwn(temporal, "checkpoint") && Array.isArray(temporal.patches)) {
|
|
103
|
+
checkpoint = migratePreIntentState(checkpoint) ?? checkpoint;
|
|
103
104
|
const owner = meta?.owner;
|
|
104
105
|
if (scope === "cwd" && expectedCwd !== undefined
|
|
105
106
|
&& (!isObject(owner) || Object.keys(owner).join(",") !== "cwd" || owner.cwd !== resolve(expectedCwd))) {
|
|
@@ -126,6 +127,10 @@ export function classifyScopeStream(
|
|
|
126
127
|
} else if (scope === "cwd" && expectedCwd !== undefined) {
|
|
127
128
|
throw new Error("State Flow CWD scope identity is missing");
|
|
128
129
|
}
|
|
130
|
+
if (isObject(legacyCheckpoint) && isObject(legacyCheckpoint.state)) {
|
|
131
|
+
const migrated = migratePreIntentState(legacyCheckpoint.state);
|
|
132
|
+
if (migrated) legacyCheckpoint = { ...legacyCheckpoint, state: migrated };
|
|
133
|
+
}
|
|
129
134
|
const stream = { checkpoint: legacyCheckpoint, patches };
|
|
130
135
|
validateScopeStream(stream, scope);
|
|
131
136
|
return { kind: "present", stream };
|
|
@@ -4,27 +4,24 @@ import { StringEnum, Type } from "@earendil-works/pi-ai";
|
|
|
4
4
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { Text } from "@earendil-works/pi-tui";
|
|
7
|
-
import { assistantToolCallCount, finalizedAssistantResponse, stateFlowProtocol } from "./protocol.ts";
|
|
7
|
+
import { assistantToolCallCount, finalizedAssistantResponse, formatPatchStateArguments, normalizePatchStateArguments, separatedFailure, separatedOutput, stateFlowProtocol } from "./protocol.ts";
|
|
8
8
|
import { createPassiveContinuation, currentRunTrajectory, lazyNavigationHint, passiveContinuationMessages, runtimeContextMessage, syntheticUser, VALIDATION_MESSAGE_TYPE, withoutPrivateValidation, type PassiveContinuation } from "./context.ts";
|
|
9
9
|
import { ArtifactReadTracker } from "./acquisition.ts";
|
|
10
10
|
import { loadStateFlowConfig } from "./config.ts";
|
|
11
11
|
import { createStateFlowTelegramAdapter, type StateFlowTelegramControlResult, type StateFlowTelegramLoader } from "./telegram.ts";
|
|
12
|
-
import { isAbsolute,
|
|
12
|
+
import { isAbsolute, resolve } from "node:path";
|
|
13
13
|
import { SkillReadTracker } from "./skills.ts";
|
|
14
14
|
import { emptySnapshot, migrationFailure, persistableSnapshot, RevisionUnavailableError, type Snapshot } from "./snapshot.ts";
|
|
15
15
|
import { readNativeSessionHeader } from "./continuation.ts";
|
|
16
16
|
import { MissingSessionRuntimeError, SharedScopeRemovalConflictError, TemporalRuntime, type RuntimePublication } from "./runtime.ts";
|
|
17
17
|
import { emptyState, overlayStates, projectStateForModel, type AtomicScopePatches, type MaterializedState, type ScopedStates, type StateScope } from "./state.ts";
|
|
18
18
|
import { commitScopedTransition, stageAtomicScopePatches, stageScopedTransition, validateFinalEligibility, type StagedScopedTransition } from "./transition.ts";
|
|
19
|
-
import { discoverSnapshotData, hasPriorConversation, isNewSession, SNAPSHOT_ENTRY_TYPE } from "./session.ts";
|
|
19
|
+
import { discoverSnapshotData, findAssistantToolBatch, findPassiveStopBoundary, hasPriorConversation, isNewSession, retainsPhysicalSessionProjection, SNAPSHOT_ENTRY_TYPE } from "./session.ts";
|
|
20
20
|
import { compactStatus, detailedStatus, STATUS_KEY, type PendingPublicationDiagnostic, type StatusDiagnostics } from "./status.ts";
|
|
21
21
|
import { completeRun, prepareRun, resumeEpisode, startEpisode, stopEpisode } from "./episode.ts";
|
|
22
22
|
import { recoverSnapshot } from "./recovery.ts";
|
|
23
23
|
import type { RehydrationPhase } from "./rehydration.ts";
|
|
24
|
-
import { resolveRemotePublicationPolicy, serializeRemotePublicationPolicyDocument } from "./publication.ts";
|
|
25
|
-
import { coalescePublicationTarget, createPublicationQueue, type PublicationQueueState } from "./publication.ts";
|
|
26
|
-
import { acquirePublicationWorkerLease, loadPublicationQueue, publicationQueuePath, removePublicationQueue, savePublicationQueue } from "./publication.ts";
|
|
27
|
-
import { runPublicationWorker } from "./publication.ts";
|
|
24
|
+
import { loadPublicationQueue, publicationQueuePath, PublicationWorkerController, resolveRemotePublicationPolicy, serializeRemotePublicationPolicyDocument, type PublicationQueueState } from "./publication.ts";
|
|
28
25
|
import { getKnowledgeRoot, GlobalMarkdownDiscovery } from "./discovery.ts";
|
|
29
26
|
import { canonicalJson, isObject, sameJson } from "./json.ts";
|
|
30
27
|
import { readProjectedState, readStatePath } from "./query.ts";
|
|
@@ -35,7 +32,7 @@ import {
|
|
|
35
32
|
type SessionAddress,
|
|
36
33
|
} from "./durable.ts";
|
|
37
34
|
import { projectRecentTransitionsWithLimit, RECENT_TRANSITION_LIMIT } from "./history.ts";
|
|
38
|
-
import {
|
|
35
|
+
import { StateFlowDiagnosticWriter, stateFlowLogPath, type DiagnosticExtras, type StateFlowDiagnosticCategory } from "./logging.ts";
|
|
39
36
|
import { isGitCommitAncestor, pushGitCommit, pushGitTarget, resolveGitPushDestination } from "./git.ts";
|
|
40
37
|
import {
|
|
41
38
|
ORDINARY_ARTIFACT_COMPILER,
|
|
@@ -54,64 +51,13 @@ export interface StateFlowExtensionOptions {
|
|
|
54
51
|
passive?: { bootstrap?: boolean; tools?: boolean };
|
|
55
52
|
}
|
|
56
53
|
|
|
54
|
+
export { formatPatchStateArguments, normalizePatchStateArguments };
|
|
55
|
+
|
|
57
56
|
export const PATCH_STATE_TOOL_NAME = "patch_state";
|
|
58
57
|
export const READ_STATE_TOOL_NAME = "read_state";
|
|
59
58
|
export const MAX_FALLBACK_ATTEMPTS: number = 2;
|
|
60
59
|
const PASSIVE_STOP_ENTRY_TYPE = "state-flow-passive-stop";
|
|
61
60
|
const PUBLICATION_SHUTDOWN_WAIT_MS = 2_000;
|
|
62
|
-
const PATCH_DISPLAY_SECTION_KEYS = new Set([
|
|
63
|
-
"global",
|
|
64
|
-
"cwd",
|
|
65
|
-
"session",
|
|
66
|
-
"artifacts",
|
|
67
|
-
"contract",
|
|
68
|
-
"working",
|
|
69
|
-
"response",
|
|
70
|
-
"final",
|
|
71
|
-
]);
|
|
72
|
-
|
|
73
|
-
/** Keep successful patch JSON valid while separating adjacent scopes and memory sections visually. */
|
|
74
|
-
export function formatPatchStateArguments(args: unknown): string {
|
|
75
|
-
const seenAtIndent = new Set<number>();
|
|
76
|
-
return JSON.stringify(args, null, 2).split("\n").flatMap((line) => {
|
|
77
|
-
const indent = line.length - line.trimStart().length;
|
|
78
|
-
const match = /^(\s+)"([^"]+)":/.exec(line);
|
|
79
|
-
if (match === null || !PATCH_DISPLAY_SECTION_KEYS.has(match[2])) {
|
|
80
|
-
for (const seenIndent of seenAtIndent) {
|
|
81
|
-
if (seenIndent > indent) seenAtIndent.delete(seenIndent);
|
|
82
|
-
}
|
|
83
|
-
return [line];
|
|
84
|
-
}
|
|
85
|
-
const separator = seenAtIndent.has(indent) ? [""] : [];
|
|
86
|
-
seenAtIndent.add(indent);
|
|
87
|
-
return [...separator, line];
|
|
88
|
-
}).join("\n");
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Normalize a bounded compatibility superset without advertising aliases in the model-facing contract. */
|
|
92
|
-
export function normalizePatchStateArguments(args: unknown): any {
|
|
93
|
-
if (!isObject(args) || !Object.hasOwn(args, "final")) return args;
|
|
94
|
-
const value = args.final;
|
|
95
|
-
let final: boolean;
|
|
96
|
-
if (typeof value === "boolean") final = value;
|
|
97
|
-
else if (value === 1) final = true;
|
|
98
|
-
else if (value === 0) final = false;
|
|
99
|
-
else if (typeof value === "string" && value.trim().toLowerCase() === "true") final = true;
|
|
100
|
-
else if (typeof value === "string" && value.trim().toLowerCase() === "false") final = false;
|
|
101
|
-
else return args;
|
|
102
|
-
return { ...args, final };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** Keep tool output visually separated from its heading with exactly one leading newline. */
|
|
106
|
-
function separatedOutput(text: string): string {
|
|
107
|
-
return `\n${text.replace(/^\n+/, "")}`;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** Keep a failed tool invocation visually separated from its rendered error without changing error semantics. */
|
|
111
|
-
function separatedFailure(error: unknown): Error {
|
|
112
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
113
|
-
return new Error(separatedOutput(message), error instanceof Error ? { cause: error } : undefined);
|
|
114
|
-
}
|
|
115
61
|
|
|
116
62
|
export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowExtensionOptions = {}): void {
|
|
117
63
|
const agentDir = options.agentDir ?? getAgentDir();
|
|
@@ -145,15 +91,22 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
145
91
|
let pendingPublication: PendingPublicationDiagnostic | undefined;
|
|
146
92
|
let rehydrationPhase: RehydrationPhase | undefined;
|
|
147
93
|
let turnPublicationTarget: string | undefined;
|
|
148
|
-
const activePublicationWorkers = new Map<string, { controller: AbortController; done: Promise<void> }>();
|
|
149
|
-
let publicationStopped = false;
|
|
150
94
|
let publicationShutdown: Promise<void> | undefined;
|
|
151
95
|
const repositoryRoot = resolve(options.repositoryRoot ?? config.directory);
|
|
96
|
+
const diagnosticWriter = new StateFlowDiagnosticWriter(config.logging, stateFlowLogPath(agentDir), repositoryRoot, (message) => activeContext?.ui.notify(message, "warning"));
|
|
97
|
+
const publicationWorker = new PublicationWorkerController({
|
|
98
|
+
resolveDestination: () => resolveGitPushDestination(repositoryRoot),
|
|
99
|
+
isAncestor: (ancestor, descendant) => isGitCommitAncestor(repositoryRoot, ancestor, descendant),
|
|
100
|
+
push: (destination, target, signal) => pushGitTarget(repositoryRoot, destination, target, signal),
|
|
101
|
+
onDiverged: (dropped, target) => activeContext && recordDiagnostic(
|
|
102
|
+
`Retired publication queue target ${dropped.target} after a journal lineage rewrite; retargeting to ${target}`,
|
|
103
|
+
"publication-conflict", activeContext,
|
|
104
|
+
),
|
|
105
|
+
});
|
|
152
106
|
const skillReads = new SkillReadTracker();
|
|
153
107
|
const artifactReads = new ArtifactReadTracker();
|
|
154
108
|
const globalMarkdown = new GlobalMarkdownDiscovery(options.knowledgeRoot ?? getKnowledgeRoot(agentDir));
|
|
155
109
|
let artifactInvalidations: ArtifactInvalidationRequest[] = [];
|
|
156
|
-
let loggingWarningReported = false;
|
|
157
110
|
let telegramStartPending = false;
|
|
158
111
|
|
|
159
112
|
function sessionAddress(ctx: ExtensionContext): SessionAddress {
|
|
@@ -206,27 +159,6 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
206
159
|
artifactReads.clear();
|
|
207
160
|
}
|
|
208
161
|
|
|
209
|
-
function passiveStopBoundary(ctx: ExtensionContext): { at: number; from?: number } | undefined {
|
|
210
|
-
for (const entry of [...ctx.sessionManager.getBranch()].reverse()) {
|
|
211
|
-
try {
|
|
212
|
-
if (entry?.type !== "custom" || entry.customType !== PASSIVE_STOP_ENTRY_TYPE) continue;
|
|
213
|
-
const { at, from, reset, owner } = (entry.data as { at?: unknown; from?: unknown; reset?: unknown; owner?: unknown } | undefined) ?? {};
|
|
214
|
-
if (reset === true && owner === ctx.sessionManager.getSessionId()) return undefined;
|
|
215
|
-
if (typeof at === "number" && Number.isSafeInteger(at) && at >= 0) return {
|
|
216
|
-
at,
|
|
217
|
-
...(typeof from === "number" && Number.isSafeInteger(from) && from >= 0 ? { from } : {}),
|
|
218
|
-
};
|
|
219
|
-
} catch {
|
|
220
|
-
// A hostile unrelated branch entry cannot manufacture or suppress a valid marker.
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
return undefined;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function retainsPhysicalSessionProjection(reason: unknown): boolean {
|
|
227
|
-
return reason === undefined || reason === "startup" || reason === "reload" || reason === "resume";
|
|
228
|
-
}
|
|
229
|
-
|
|
230
162
|
function deferArtifactRefresh(): void {
|
|
231
163
|
artifactInvalidations = [];
|
|
232
164
|
artifactReads.setCandidates([]);
|
|
@@ -289,21 +221,6 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
289
221
|
: active.filter((name) => !owned.includes(name)));
|
|
290
222
|
}
|
|
291
223
|
|
|
292
|
-
function assistantToolBatch(ctx: ExtensionContext, toolCallId: string): string[] | undefined {
|
|
293
|
-
for (let cursor = ctx.sessionManager.getLeafEntry(); cursor; cursor = cursor.parentId ? ctx.sessionManager.getEntry(cursor.parentId) : undefined) {
|
|
294
|
-
const entry = cursor as { type?: unknown; message?: { role?: unknown; content?: unknown } };
|
|
295
|
-
if (entry.type !== "message" || entry.message?.role !== "assistant" || !Array.isArray(entry.message.content)) continue;
|
|
296
|
-
const calls = entry.message.content.filter((block): block is { type: "toolCall"; id: string; name: string } => {
|
|
297
|
-
return typeof block === "object" && block !== null
|
|
298
|
-
&& (block as { type?: unknown }).type === "toolCall"
|
|
299
|
-
&& typeof (block as { id?: unknown }).id === "string"
|
|
300
|
-
&& typeof (block as { name?: unknown }).name === "string";
|
|
301
|
-
});
|
|
302
|
-
if (calls.some(({ id }) => id === toolCallId)) return calls.map(({ name }) => name);
|
|
303
|
-
}
|
|
304
|
-
return undefined;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
224
|
function recordPublication(publication: RuntimePublication, ctx: ExtensionContext): void {
|
|
308
225
|
const revision = publication.revision ?? publication.commit;
|
|
309
226
|
if (revision !== undefined) snapshot.meta.durableBase = revision;
|
|
@@ -330,8 +247,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
330
247
|
if (mode !== "turn-end" || target === undefined || !/^[0-9a-f]{40,64}$/.test(target)) return;
|
|
331
248
|
turnPublicationTarget = target;
|
|
332
249
|
try {
|
|
333
|
-
enqueueTurnPublication(
|
|
334
|
-
|
|
250
|
+
enqueueTurnPublication();
|
|
251
|
+
publicationWorker.launch();
|
|
335
252
|
} catch (error) {
|
|
336
253
|
ctx.ui.notify(
|
|
337
254
|
`State Flow accepted the local commit; remote publication is deferred: ${error instanceof Error ? error.message : String(error)}`,
|
|
@@ -372,95 +289,16 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
372
289
|
return true;
|
|
373
290
|
}
|
|
374
291
|
|
|
375
|
-
function enqueueTurnPublication(
|
|
292
|
+
function enqueueTurnPublication(): void {
|
|
376
293
|
const target = turnPublicationTarget;
|
|
377
294
|
turnPublicationTarget = undefined;
|
|
378
|
-
if (
|
|
379
|
-
const destination = resolveGitPushDestination(repositoryRoot);
|
|
380
|
-
if (!destination) return;
|
|
381
|
-
const path = publicationQueuePath(destination);
|
|
382
|
-
const previous = loadPublicationQueue(path);
|
|
383
|
-
const next = previous
|
|
384
|
-
? coalescePublicationTarget(previous, destination, target, (ancestor, descendant) => isGitCommitAncestor(repositoryRoot, ancestor, descendant), {
|
|
385
|
-
onDivergedLineage: (dropped) => recordDiagnostic(
|
|
386
|
-
`Retired publication queue target ${dropped.target} after a journal lineage rewrite; retargeting to ${target}`,
|
|
387
|
-
"publication-conflict", ctx,
|
|
388
|
-
),
|
|
389
|
-
})
|
|
390
|
-
: createPublicationQueue(destination, target);
|
|
391
|
-
savePublicationQueue(path, next, previous);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
function launchPublicationWorker(): void {
|
|
395
|
-
if (publicationStopped) return;
|
|
396
|
-
let destination: ReturnType<typeof resolveGitPushDestination>;
|
|
397
|
-
try {
|
|
398
|
-
destination = resolveGitPushDestination(repositoryRoot);
|
|
399
|
-
} catch (error) {
|
|
400
|
-
if (error instanceof Error && /ENOENT/.test(error.message)) return;
|
|
401
|
-
throw error;
|
|
402
|
-
}
|
|
403
|
-
if (!destination) return;
|
|
404
|
-
const path = publicationQueuePath(destination);
|
|
405
|
-
if (activePublicationWorkers.has(path)) return;
|
|
406
|
-
let queued: PublicationQueueState | undefined;
|
|
407
|
-
let lease: ReturnType<typeof acquirePublicationWorkerLease>;
|
|
408
|
-
try {
|
|
409
|
-
queued = loadPublicationQueue(path);
|
|
410
|
-
if (!queued) return;
|
|
411
|
-
lease = acquirePublicationWorkerLease(path);
|
|
412
|
-
} catch {
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
if (!lease) return;
|
|
416
|
-
const controller = new AbortController();
|
|
417
|
-
const done = runPublicationWorker(
|
|
418
|
-
queued,
|
|
419
|
-
({ target }) => pushGitTarget(repositoryRoot, destination, target, controller.signal),
|
|
420
|
-
() => loadPublicationQueue(path) ?? queued,
|
|
421
|
-
(ancestor, descendant) => isGitCommitAncestor(repositoryRoot, ancestor, descendant),
|
|
422
|
-
).then((result) => {
|
|
423
|
-
if (publicationStopped) return; // Late outcomes belong to an unconfirmed queue, not the replacement generation.
|
|
424
|
-
const current = loadPublicationQueue(path);
|
|
425
|
-
if (!current) return;
|
|
426
|
-
if (result.next === undefined) {
|
|
427
|
-
if (current.target === result.attempted.target) removePublicationQueue(path, current);
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
if (current.target === result.attempted.target || result.next.target === current.target) {
|
|
431
|
-
savePublicationQueue(path, result.next, current);
|
|
432
|
-
}
|
|
433
|
-
}).catch(() => {
|
|
434
|
-
// Queue/CAS truth remains durable; status and a later activation expose retry.
|
|
435
|
-
}).finally(() => {
|
|
436
|
-
activePublicationWorkers.delete(path);
|
|
437
|
-
try {
|
|
438
|
-
lease.release();
|
|
439
|
-
if (!publicationStopped && loadPublicationQueue(path)?.status === "pending") launchPublicationWorker();
|
|
440
|
-
} catch {
|
|
441
|
-
// Failed lease cleanup or malformed persistence stays inert until retry or repair.
|
|
442
|
-
}
|
|
443
|
-
});
|
|
444
|
-
activePublicationWorkers.set(path, { controller, done });
|
|
295
|
+
if (target) publicationWorker.enqueue(target);
|
|
445
296
|
}
|
|
446
297
|
|
|
447
298
|
function shutdownPublicationWorkers(ctx: ExtensionContext): Promise<void> {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
for (const { controller } of workers) controller.abort();
|
|
452
|
-
if (workers.length === 0) return;
|
|
453
|
-
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
454
|
-
try {
|
|
455
|
-
const completed = await Promise.race([
|
|
456
|
-
Promise.all(workers.map(({ done }) => done)).then(() => true),
|
|
457
|
-
new Promise<false>((resolve) => { timeout = setTimeout(() => resolve(false), PUBLICATION_SHUTDOWN_WAIT_MS); }),
|
|
458
|
-
]);
|
|
459
|
-
if (!completed) ctx.ui.notify(`State Flow push cleanup is unconfirmed after ${PUBLICATION_SHUTDOWN_WAIT_MS}ms; worker leases remain held until child exit.`, "warning");
|
|
460
|
-
} finally {
|
|
461
|
-
clearTimeout(timeout);
|
|
462
|
-
}
|
|
463
|
-
})();
|
|
299
|
+
return publicationShutdown ??= publicationWorker.shutdown(PUBLICATION_SHUTDOWN_WAIT_MS).then((completed) => {
|
|
300
|
+
if (!completed) ctx.ui.notify(`State Flow push cleanup is unconfirmed after ${PUBLICATION_SHUTDOWN_WAIT_MS}ms; worker leases remain held until child exit.`, "warning");
|
|
301
|
+
});
|
|
464
302
|
}
|
|
465
303
|
|
|
466
304
|
function retryPendingPush(ctx: ExtensionContext): void {
|
|
@@ -472,8 +310,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
472
310
|
if (mode === "turn-end") {
|
|
473
311
|
turnPublicationTarget = target;
|
|
474
312
|
try {
|
|
475
|
-
enqueueTurnPublication(
|
|
476
|
-
|
|
313
|
+
enqueueTurnPublication();
|
|
314
|
+
publicationWorker.launch();
|
|
477
315
|
} catch (error) {
|
|
478
316
|
ctx.ui.notify(`State Flow retained local state; asynchronous publication recovery is deferred: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
479
317
|
}
|
|
@@ -681,7 +519,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
681
519
|
ctx.ui.notify(`State Flow restored disabled: ${snapshot.meta.validation.error}`, "error");
|
|
682
520
|
}
|
|
683
521
|
if (retainsPhysicalSessionProjection(sessionStartReason) && runtime?.view) {
|
|
684
|
-
const boundary =
|
|
522
|
+
const boundary = findPassiveStopBoundary(ctx.sessionManager.getBranch(), ctx.sessionManager.getSessionId(), PASSIVE_STOP_ENTRY_TYPE);
|
|
685
523
|
if (boundary !== undefined) {
|
|
686
524
|
const continuation = createPassiveContinuation(
|
|
687
525
|
projectStateForModel(overlayStates(scopeStates.global, scopeStates.cwd, scopeStates.session)),
|
|
@@ -705,41 +543,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
705
543
|
updateUi(ctx);
|
|
706
544
|
}
|
|
707
545
|
|
|
708
|
-
interface DiagnosticExtras {
|
|
709
|
-
content?: unknown;
|
|
710
|
-
input?: unknown;
|
|
711
|
-
tool?: string;
|
|
712
|
-
toolCallId?: string;
|
|
713
|
-
resolutionAttempt?: number;
|
|
714
|
-
terminalEligible?: boolean;
|
|
715
|
-
}
|
|
716
|
-
|
|
717
546
|
function recordDiagnostic(error: string, category: StateFlowDiagnosticCategory, ctx: ExtensionContext, extras: DiagnosticExtras = {}): void {
|
|
718
|
-
|
|
719
|
-
try {
|
|
720
|
-
const path = stateFlowLogPath(agentDir);
|
|
721
|
-
const fromRepository = relative(repositoryRoot, path);
|
|
722
|
-
if (fromRepository === "" || (!isAbsolute(fromRepository) && fromRepository !== ".." && !fromRepository.startsWith(`..${sep}`))) {
|
|
723
|
-
throw new Error("diagnostic path overlaps the State Flow repository");
|
|
724
|
-
}
|
|
725
|
-
appendStateFlowDiagnostic(path, {
|
|
726
|
-
at: new Date().toISOString(),
|
|
727
|
-
sessionId: sessionAddress(ctx).id,
|
|
728
|
-
cwd: resolve(ctx.cwd),
|
|
729
|
-
category,
|
|
730
|
-
error,
|
|
731
|
-
...(extras.content === undefined ? {} : { content: projectDiagnosticContent(extras.content) }),
|
|
732
|
-
...(extras.input === undefined ? {} : { input: extras.input }),
|
|
733
|
-
...(extras.tool === undefined ? {} : { tool: extras.tool }),
|
|
734
|
-
...(extras.toolCallId === undefined ? {} : { toolCallId: extras.toolCallId }),
|
|
735
|
-
...(extras.resolutionAttempt === undefined ? {} : { resolutionAttempt: extras.resolutionAttempt }),
|
|
736
|
-
...(extras.terminalEligible === undefined ? {} : { terminalEligible: extras.terminalEligible }),
|
|
737
|
-
});
|
|
738
|
-
} catch (failure) {
|
|
739
|
-
if (loggingWarningReported) return;
|
|
740
|
-
loggingWarningReported = true;
|
|
741
|
-
ctx.ui.notify(`State Flow could not write diagnostics: ${failure instanceof Error ? failure.message : String(failure)}`, "warning");
|
|
742
|
-
}
|
|
547
|
+
diagnosticWriter.record(sessionAddress(ctx).id, ctx.cwd, error, category, extras);
|
|
743
548
|
}
|
|
744
549
|
|
|
745
550
|
function continueFallbackResolution(lastWarning: boolean): void {
|
|
@@ -1193,7 +998,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
1193
998
|
|
|
1194
999
|
pi.on("tool_call", (event, ctx) => {
|
|
1195
1000
|
if (!snapshot.config.enabled) return;
|
|
1196
|
-
const batch =
|
|
1001
|
+
const batch = findAssistantToolBatch(ctx.sessionManager, event.toolCallId);
|
|
1197
1002
|
const patchCalls = batch?.filter((name) => name === PATCH_STATE_TOOL_NAME).length ?? 0;
|
|
1198
1003
|
if (patchCalls > 0) {
|
|
1199
1004
|
if (patchCalls !== 1) {
|
|
@@ -1264,8 +1069,8 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
1264
1069
|
responseCommitted = true;
|
|
1265
1070
|
bootstrapContinuation = undefined;
|
|
1266
1071
|
rehydrationPhase = "step";
|
|
1267
|
-
enqueueTurnPublication(
|
|
1268
|
-
if (snapshot.meta.remotePublication?.mode === "turn-end")
|
|
1072
|
+
enqueueTurnPublication();
|
|
1073
|
+
if (snapshot.meta.remotePublication?.mode === "turn-end") publicationWorker.launch();
|
|
1269
1074
|
completedRunAccepted = !wasBootstrap;
|
|
1270
1075
|
} catch (error) {
|
|
1271
1076
|
if (!responseCommitted && specification !== undefined) snapshot.meta.specification = specification;
|
|
@@ -1292,7 +1097,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
1292
1097
|
telegramStartPending = false;
|
|
1293
1098
|
startStateFlow(ctx);
|
|
1294
1099
|
}
|
|
1295
|
-
if (snapshot.meta.remotePublication?.mode === "turn-end")
|
|
1100
|
+
if (snapshot.meta.remotePublication?.mode === "turn-end") publicationWorker.launch();
|
|
1296
1101
|
if (!completedRunAccepted || compactionStopped || !snapshot.config.enabled || snapshot.meta.bootstrap || resolutionPending
|
|
1297
1102
|
|| compactionInFlight || !ctx.isIdle() || ctx.hasPendingMessages() || !snapshot.meta.durableBase
|
|
1298
1103
|
|| !shouldRequestStateFlowCompaction(ctx.getContextUsage())) return;
|
|
@@ -1314,7 +1119,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
1314
1119
|
rehydrationPhase = event.reason === "resume" ? "resume-bootstrap" : "new-bootstrap";
|
|
1315
1120
|
restoreActiveBranch(ctx, event.reason);
|
|
1316
1121
|
retryPendingPush(ctx);
|
|
1317
|
-
if (snapshot.meta.remotePublication?.mode === "turn-end")
|
|
1122
|
+
if (snapshot.meta.remotePublication?.mode === "turn-end") publicationWorker.launch();
|
|
1318
1123
|
updateUi(ctx);
|
|
1319
1124
|
void telegram.ensure();
|
|
1320
1125
|
});
|
|
@@ -22,7 +22,7 @@ export interface RecentTransition extends AcceptedTransition {
|
|
|
22
22
|
|
|
23
23
|
export type RecentTransitionWindow = RecentTransition[];
|
|
24
24
|
const SCOPES = new Set<StateScope>(["global", "cwd", "session"]);
|
|
25
|
-
const PATCH_KEYS = new Set(["artifacts", "contract", "working", "response", "lazy"]);
|
|
25
|
+
const PATCH_KEYS = new Set(["artifacts", "contract", "working", "intents", "response", "lazy"]);
|
|
26
26
|
|
|
27
27
|
/** Normalize accepted replacements into recursive-merge replay, including removals. */
|
|
28
28
|
function replayPatch(before: JsonObject, after: JsonObject): JsonObject {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Domain: opt-in diagnostic capture for rejected State Flow resolutions.
|
|
2
2
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { isObject } from "./json.ts";
|
|
5
5
|
|
|
6
6
|
export type StateFlowDiagnosticCategory = "invalid-patch" | "publication-conflict" | "terminal-pending" | "finalization";
|
|
@@ -45,3 +45,55 @@ export function appendStateFlowDiagnostic(path: string, record: StateFlowDiagnos
|
|
|
45
45
|
mkdirSync(dirname(path), { recursive: true });
|
|
46
46
|
appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
export interface DiagnosticExtras {
|
|
50
|
+
content?: unknown;
|
|
51
|
+
input?: unknown;
|
|
52
|
+
tool?: string;
|
|
53
|
+
toolCallId?: string;
|
|
54
|
+
resolutionAttempt?: number;
|
|
55
|
+
terminalEligible?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Own diagnostic path safety, projection, persistence, and one-shot failure reporting. */
|
|
59
|
+
export class StateFlowDiagnosticWriter {
|
|
60
|
+
private warningReported = false;
|
|
61
|
+
private readonly enabled: boolean;
|
|
62
|
+
private readonly path: string;
|
|
63
|
+
private readonly repositoryRoot: string;
|
|
64
|
+
private readonly notify: (message: string) => void;
|
|
65
|
+
|
|
66
|
+
constructor(enabled: boolean, path: string, repositoryRoot: string, notify: (message: string) => void) {
|
|
67
|
+
this.enabled = enabled;
|
|
68
|
+
this.path = path;
|
|
69
|
+
this.repositoryRoot = repositoryRoot;
|
|
70
|
+
this.notify = notify;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
record(sessionId: string, cwd: string, error: string, category: StateFlowDiagnosticCategory, extras: DiagnosticExtras = {}): void {
|
|
74
|
+
if (!this.enabled) return;
|
|
75
|
+
try {
|
|
76
|
+
const fromRepository = relative(this.repositoryRoot, this.path);
|
|
77
|
+
if (fromRepository === "" || (!isAbsolute(fromRepository) && fromRepository !== ".." && !fromRepository.startsWith(`..${sep}`))) {
|
|
78
|
+
throw new Error("diagnostic path overlaps the State Flow repository");
|
|
79
|
+
}
|
|
80
|
+
appendStateFlowDiagnostic(this.path, {
|
|
81
|
+
at: new Date().toISOString(),
|
|
82
|
+
sessionId,
|
|
83
|
+
cwd: resolve(cwd),
|
|
84
|
+
category,
|
|
85
|
+
error,
|
|
86
|
+
...(extras.content === undefined ? {} : { content: projectDiagnosticContent(extras.content) }),
|
|
87
|
+
...(extras.input === undefined ? {} : { input: extras.input }),
|
|
88
|
+
...(extras.tool === undefined ? {} : { tool: extras.tool }),
|
|
89
|
+
...(extras.toolCallId === undefined ? {} : { toolCallId: extras.toolCallId }),
|
|
90
|
+
...(extras.resolutionAttempt === undefined ? {} : { resolutionAttempt: extras.resolutionAttempt }),
|
|
91
|
+
...(extras.terminalEligible === undefined ? {} : { terminalEligible: extras.terminalEligible }),
|
|
92
|
+
});
|
|
93
|
+
} catch (failure) {
|
|
94
|
+
if (this.warningReported) return;
|
|
95
|
+
this.warningReported = true;
|
|
96
|
+
this.notify(`State Flow could not write diagnostics: ${failure instanceof Error ? failure.message : String(failure)}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
type OwnedFileUpdate,
|
|
14
14
|
} from "./durable.ts";
|
|
15
15
|
import { parseSessionRuntime, serializeSessionRuntime } from "./snapshot.ts";
|
|
16
|
-
import type
|
|
16
|
+
import { migratePreIntentState, type StateScope } from "./state.ts";
|
|
17
17
|
|
|
18
18
|
export interface LegacyStorageMigration {
|
|
19
19
|
bases: DurableFileBase[];
|
|
@@ -57,9 +57,14 @@ function migrationDirectories(cwd: string, sessionId: string, root: string, sess
|
|
|
57
57
|
try { sessionScopeKey(entry.name); } catch { continue; }
|
|
58
58
|
const directory = join(cwdDirectory, entry.name);
|
|
59
59
|
let meta: unknown;
|
|
60
|
+
let runtime: unknown;
|
|
60
61
|
try { meta = JSON.parse(readFileSync(join(directory, "meta.json"), "utf8")); }
|
|
61
62
|
catch { continue; }
|
|
62
|
-
|
|
63
|
+
try { runtime = JSON.parse(readFileSync(join(directory, "runtime.json"), "utf8")); }
|
|
64
|
+
catch { /* predecessor sessions keep identity in meta.json */ }
|
|
65
|
+
const identity = meta && typeof meta === "object" && !Array.isArray(meta) && (meta as { identity?: unknown }).identity !== undefined
|
|
66
|
+
? (meta as { identity?: unknown }).identity
|
|
67
|
+
: runtime && typeof runtime === "object" && !Array.isArray(runtime) ? (runtime as { identity?: unknown }).identity : undefined;
|
|
63
68
|
if (!identity || typeof identity !== "object" || Array.isArray(identity)) continue;
|
|
64
69
|
const owner = identity as { cwd?: unknown; sessionId?: unknown };
|
|
65
70
|
if (owner.cwd === cwdOwner && typeof owner.sessionId === "string" && owner.sessionId.length > 0) {
|
|
@@ -85,7 +90,14 @@ export function hasCwdMaterialization(cwd: string, repositoryRoot: string): bool
|
|
|
85
90
|
return false;
|
|
86
91
|
}
|
|
87
92
|
|
|
88
|
-
|
|
93
|
+
function checkpointNeedsIntentMigration(source: string): boolean {
|
|
94
|
+
const checkpoint = JSON.parse(source) as unknown;
|
|
95
|
+
if (migratePreIntentState(checkpoint) !== undefined) return true;
|
|
96
|
+
return checkpoint !== null && typeof checkpoint === "object" && !Array.isArray(checkpoint)
|
|
97
|
+
&& migratePreIntentState((checkpoint as { state?: unknown }).state) !== undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Detect predecessor snapshots, temporal envelopes, or pre-intents semantic states without mutating the store. */
|
|
89
101
|
export function hasLegacyStateSources(
|
|
90
102
|
cwd: string,
|
|
91
103
|
sessionId: string,
|
|
@@ -94,8 +106,10 @@ export function hasLegacyStateSources(
|
|
|
94
106
|
): boolean {
|
|
95
107
|
const root = resolve(repositoryRoot);
|
|
96
108
|
return migrationDirectories(cwd, sessionId, root, sessionKey).some(({ directory, scope }) => {
|
|
97
|
-
|
|
109
|
+
const checkpointPath = join(directory, "checkpoint.json");
|
|
110
|
+
if (lstatSync(checkpointPath, { throwIfNoEntry: false }) === undefined) return false;
|
|
98
111
|
try {
|
|
112
|
+
if (checkpointNeedsIntentMigration(readFileSync(checkpointPath, "utf8"))) return true;
|
|
99
113
|
const meta = JSON.parse(readFileSync(join(directory, "meta.json"), "utf8"));
|
|
100
114
|
if (meta === null || typeof meta !== "object" || !("temporal" in meta)) return true;
|
|
101
115
|
return scope === "session"
|