@llblab/pi-kit 0.19.2 → 0.21.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 +13 -0
- package/README.md +3 -3
- package/node_modules/@llblab/pi-state-flow/AGENTS.md +6 -6
- package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +9 -0
- package/node_modules/@llblab/pi-state-flow/README.md +4 -4
- package/node_modules/@llblab/pi-state-flow/dist/index.d.ts +2 -2
- package/node_modules/@llblab/pi-state-flow/dist/index.js +2 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/durable.d.ts +1 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/durable.js +7 -3
- package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +41 -11
- package/node_modules/@llblab/pi-state-flow/dist/lib/git.d.ts +6 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/git.js +94 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +2 -5
- package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.d.ts +2 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/runtime.js +11 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +4 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +8 -2
- package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.d.ts +6 -1
- package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +19 -6
- package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.d.ts +5 -0
- package/node_modules/@llblab/pi-state-flow/dist/lib/temporal.js +27 -3
- package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +2 -2
- package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
- package/node_modules/@llblab/pi-state-flow/docs/architecture.md +10 -8
- package/node_modules/@llblab/pi-state-flow/docs/lazy-state.md +2 -2
- package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +4 -3
- package/node_modules/@llblab/pi-state-flow/docs/usage.md +5 -5
- package/node_modules/@llblab/pi-state-flow/index.ts +2 -2
- package/node_modules/@llblab/pi-state-flow/lib/durable.ts +8 -3
- package/node_modules/@llblab/pi-state-flow/lib/extension.ts +41 -9
- package/node_modules/@llblab/pi-state-flow/lib/git.ts +83 -1
- package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +2 -4
- package/node_modules/@llblab/pi-state-flow/lib/runtime.ts +11 -0
- package/node_modules/@llblab/pi-state-flow/lib/status.ts +10 -4
- package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +23 -6
- package/node_modules/@llblab/pi-state-flow/lib/temporal.ts +29 -3
- package/node_modules/@llblab/pi-state-flow/lib/transition.ts +2 -2
- package/node_modules/@llblab/pi-state-flow/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/AGENTS.md +9 -7
- package/node_modules/@llblab/pi-telegram/BACKLOG.md +13 -32
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +11 -0
- package/node_modules/@llblab/pi-telegram/README.md +4 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +2 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +3 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +93 -9
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +9 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +231 -71
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +4 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +34 -7
- package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +63 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +3 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +13 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +2 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.js +25 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +1 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +32 -12
- package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +10 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +22 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +2 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +1 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +1 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/sync.d.ts +9 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +41 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +9 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +23 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +7 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +2 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +68 -37
- package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +6 -4
- package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +134 -61
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +9 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +49 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +77 -6
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +384 -8
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.d.ts +3 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.js +6 -0
- package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +21 -14
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +31 -10
- package/node_modules/@llblab/pi-telegram/docs/updates.md +2 -0
- package/node_modules/@llblab/pi-telegram/lib/bindings.ts +5 -7
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +69 -12
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +236 -94
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +39 -9
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +69 -2
- package/node_modules/@llblab/pi-telegram/lib/journal.ts +14 -0
- package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +23 -4
- package/node_modules/@llblab/pi-telegram/lib/locks.ts +32 -13
- package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -1
- package/node_modules/@llblab/pi-telegram/lib/polling.ts +2 -2
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +2 -3
- package/node_modules/@llblab/pi-telegram/lib/sync.ts +45 -1
- package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +30 -2
- package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +11 -3
- package/node_modules/@llblab/pi-telegram/lib/threads.ts +73 -36
- package/node_modules/@llblab/pi-telegram/lib/updates.ts +145 -78
- package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +67 -4
- package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +465 -7
- package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +7 -0
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- package/package.json +3 -3
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
type SessionAddress,
|
|
26
26
|
} from "./durable.ts";
|
|
27
27
|
import { completeRun, prepareRun, resumeEpisode, startEpisode, stopEpisode } from "./episode.ts";
|
|
28
|
-
import { backupCurrentStateFlowFiles } from "./git.ts";
|
|
28
|
+
import { backupCurrentStateFlowFiles, pushCurrentStateFlowBackup } from "./git.ts";
|
|
29
29
|
import { projectRecentTransitionsWithLimit } from "./history.ts";
|
|
30
30
|
import { isObject, presentationJson, sameJson, type JsonObject } from "./json.ts";
|
|
31
31
|
import { StateFlowDiagnosticWriter, stateFlowLogPath, type DiagnosticExtras, type StateFlowDiagnosticCategory } from "./logging.ts";
|
|
@@ -40,6 +40,7 @@ import { emptySnapshot, migrationFailure, type Snapshot } from "./snapshot.ts";
|
|
|
40
40
|
import { emptyState, overlayStates, projectStateForModel, type AtomicScopePatches, type MaterializedState, type ModelState, type ScopedStates, type StateScope } from "./state.ts";
|
|
41
41
|
import { compactStatus, detailedStatus, STATUS_KEY, type StatusDiagnostics } from "./status.ts";
|
|
42
42
|
import { createStateFlowTelegramAdapter, type StateFlowTelegramControlResult, type StateFlowTelegramLoader, type StateFlowTelegramScope } from "./telegram.ts";
|
|
43
|
+
import { temporalScopeRevisions, type ScopeRevisions } from "./temporal.ts";
|
|
43
44
|
import { commitScopedTransition, stageAtomicScopePatches, stageScopedTransition, type StagedScopedTransition } from "./transition.ts";
|
|
44
45
|
|
|
45
46
|
export interface StateFlowExtensionOptions {
|
|
@@ -85,7 +86,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
85
86
|
let activeContext: ExtensionContext | undefined;
|
|
86
87
|
let rehydrationPhase: RehydrationPhase | undefined;
|
|
87
88
|
const repositoryRoot = resolve(options.repositoryRoot ?? config.directory);
|
|
88
|
-
const diagnosticWriter = new StateFlowDiagnosticWriter(config.logging, stateFlowLogPath(agentDir), repositoryRoot, (message) =>
|
|
89
|
+
const diagnosticWriter = new StateFlowDiagnosticWriter(config.logging, stateFlowLogPath(agentDir), repositoryRoot, (message) => notifyActiveContext(message));
|
|
89
90
|
let backupPending = false;
|
|
90
91
|
const skillReads = new SkillReadTracker(hashSkillSource, (path) => activeContext
|
|
91
92
|
? registeredSkillResolver(activeContext.cwd, pi.getCommands())(path)
|
|
@@ -100,6 +101,14 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
100
101
|
return resolveSessionAddress(ctx.sessionManager.getSessionFile(), ctx.sessionManager.getSessionId(), ctx.sessionManager.getHeader()?.timestamp);
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
function notifyActiveContext(message: string): void {
|
|
105
|
+
try {
|
|
106
|
+
activeContext?.ui.notify(message, "warning");
|
|
107
|
+
} catch {
|
|
108
|
+
// An asynchronous push attempt can outlive the Pi context that launched it.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
103
112
|
function createRuntime(ctx: ExtensionContext): TemporalRuntime {
|
|
104
113
|
return new TemporalRuntime(ctx.cwd, sessionAddress(ctx), repositoryRoot, undefined, config.historyLimit);
|
|
105
114
|
}
|
|
@@ -134,8 +143,14 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
134
143
|
if ("boundary" in checkpoint) branchStartsWithoutRuntime = false;
|
|
135
144
|
}
|
|
136
145
|
|
|
146
|
+
function scopeRevisions(): ScopeRevisions {
|
|
147
|
+
return runtime?.view
|
|
148
|
+
? temporalScopeRevisions(runtime.view)
|
|
149
|
+
: { global: 0, cwd: 0, session: 0 };
|
|
150
|
+
}
|
|
151
|
+
|
|
137
152
|
function updateUi(ctx: ExtensionContext): void {
|
|
138
|
-
ctx.ui.setStatus(STATUS_KEY, compactStatus(snapshot, (color, text) => ctx.ui.theme.fg(color, text)));
|
|
153
|
+
ctx.ui.setStatus(STATUS_KEY, compactStatus(snapshot, scopeRevisions(), (color, text) => ctx.ui.theme.fg(color, text)));
|
|
139
154
|
}
|
|
140
155
|
|
|
141
156
|
function clearRunTransient(): void {
|
|
@@ -202,6 +217,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
202
217
|
if (Object.keys(removals).length > 0) {
|
|
203
218
|
const stage = stageAtomicScopePatches(scopeStates, removals, [], runtime.causalBasis());
|
|
204
219
|
commitStage(stage, ctx, false);
|
|
220
|
+
updateUi(ctx);
|
|
205
221
|
}
|
|
206
222
|
refreshArtifactHints();
|
|
207
223
|
}
|
|
@@ -217,10 +233,13 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
217
233
|
|
|
218
234
|
function refreshTelegramStateView(scope: StateFlowTelegramScope): void {
|
|
219
235
|
if (scope === "session" || scope === "effective") assertSelectedBranchAvailable();
|
|
220
|
-
if (runtime?.view)
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
236
|
+
if (!runtime?.view) {
|
|
237
|
+
if (!activeContext) throw new Error("State Flow is not attached to an active session yet");
|
|
238
|
+
runtime ??= createRuntime(activeContext);
|
|
239
|
+
runtime.loadPassive();
|
|
240
|
+
} else if (scope !== "session") {
|
|
241
|
+
runtime.refreshShared();
|
|
242
|
+
}
|
|
224
243
|
installScopeStates();
|
|
225
244
|
}
|
|
226
245
|
|
|
@@ -319,6 +338,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
319
338
|
head: structuredClone(view.lineage.at(-1)!),
|
|
320
339
|
historyDepth: view.lineage.length - 1,
|
|
321
340
|
tailCounts: { global: view.scopes.global.patches.length, cwd: view.scopes.cwd.patches.length, session: view.scopes.session.patches.length },
|
|
341
|
+
revisions: temporalScopeRevisions(view),
|
|
322
342
|
} }),
|
|
323
343
|
staleArtifacts,
|
|
324
344
|
...(durableStateError === undefined ? {} : { durableStateError }),
|
|
@@ -699,6 +719,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
699
719
|
snapshot: () => ({
|
|
700
720
|
enabled: snapshot.config.enabled,
|
|
701
721
|
step: snapshot.meta.step,
|
|
722
|
+
revisions: scopeRevisions(),
|
|
702
723
|
bootstrap: snapshot.meta.bootstrap === true,
|
|
703
724
|
startPending: telegramStartPending,
|
|
704
725
|
}),
|
|
@@ -709,6 +730,7 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
709
730
|
: scopeStates[scope];
|
|
710
731
|
return { ...projectModelState(selected), lazy: structuredClone(selected.lazy) };
|
|
711
732
|
},
|
|
733
|
+
revisions: () => scopeRevisions(),
|
|
712
734
|
canStartNow: () => activeContext === undefined || activeContext.isIdle(),
|
|
713
735
|
start: () => {
|
|
714
736
|
if (!activeContext) throw new Error("State Flow is not attached to an active session yet");
|
|
@@ -909,7 +931,16 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
909
931
|
if (!backupPending) return;
|
|
910
932
|
backupPending = false;
|
|
911
933
|
try {
|
|
912
|
-
if (existsSync(join(repositoryRoot, ".git")))
|
|
934
|
+
if (existsSync(join(repositoryRoot, ".git"))) {
|
|
935
|
+
backupCurrentStateFlowFiles(repositoryRoot);
|
|
936
|
+
const pushSessionId = sessionAddress(ctx).id;
|
|
937
|
+
const pushCwd = ctx.cwd;
|
|
938
|
+
void pushCurrentStateFlowBackup(repositoryRoot).catch((error) => {
|
|
939
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
940
|
+
diagnosticWriter.record(pushSessionId, pushCwd, message, "publication-conflict");
|
|
941
|
+
notifyActiveContext(`State Flow accepted canonical state; Git backup push failed: ${message}`);
|
|
942
|
+
});
|
|
943
|
+
}
|
|
913
944
|
} catch (error) {
|
|
914
945
|
const message = error instanceof Error ? error.message : String(error);
|
|
915
946
|
recordDiagnostic(message, "publication-conflict", ctx);
|
|
@@ -950,10 +981,11 @@ export default function stateFlowExtension(pi: ExtensionAPI, options: StateFlowE
|
|
|
950
981
|
runAnchorTimestamp = undefined;
|
|
951
982
|
restoreActiveBranch(ctx);
|
|
952
983
|
});
|
|
953
|
-
pi.on("session_shutdown", (_event,
|
|
984
|
+
pi.on("session_shutdown", (_event, _ctx) => {
|
|
954
985
|
telegramStartPending = false;
|
|
955
986
|
compactionStopped = true;
|
|
956
987
|
completedRunAccepted = false;
|
|
988
|
+
activeContext = undefined;
|
|
957
989
|
telegram.dispose();
|
|
958
990
|
});
|
|
959
991
|
}
|
|
@@ -2,13 +2,19 @@
|
|
|
2
2
|
import { closeSync, constants, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
5
|
-
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
6
|
import { captureOwnedFileBases, isStateFlowOwnedPath } from "./durable.ts";
|
|
7
7
|
import { acquirePublicationLock, withStoragePublicationLock } from "./storage.ts";
|
|
8
8
|
|
|
9
9
|
const GIT_TIMEOUT_MS = 15_000;
|
|
10
10
|
const STATE_FLOW_COMMIT_TRAILER = "State-Flow-Durable: v1";
|
|
11
11
|
|
|
12
|
+
function redactGitDiagnostic(value: string): string {
|
|
13
|
+
return value
|
|
14
|
+
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, "$1***@")
|
|
15
|
+
.replace(/([?&](?:access_token|auth|password|token)=)[^&\s]+/gi, "$1***");
|
|
16
|
+
}
|
|
17
|
+
|
|
12
18
|
interface GitResult {
|
|
13
19
|
status: number;
|
|
14
20
|
stdout: string;
|
|
@@ -134,6 +140,22 @@ function currentBranchRef(repositoryRoot: string): string {
|
|
|
134
140
|
return result.stdout.trim();
|
|
135
141
|
}
|
|
136
142
|
|
|
143
|
+
function configuredPushDestination(repositoryRoot: string): { remote: string; ref: string } | undefined {
|
|
144
|
+
const branchRef = currentBranchRef(repositoryRoot);
|
|
145
|
+
const branch = branchRef.slice("refs/heads/".length);
|
|
146
|
+
const configuredRemote = git(repositoryRoot, ["config", "--get", `branch.${branch}.remote`], { allowFailure: true });
|
|
147
|
+
if (configuredRemote.status > 1) throw new Error(configuredRemote.stderr || "Cannot inspect State Flow backup remote configuration");
|
|
148
|
+
if (configuredRemote.status !== 0 || configuredRemote.stdout.trim().length === 0) return undefined;
|
|
149
|
+
const remote = configuredRemote.stdout.trim();
|
|
150
|
+
if (remote === ".") throw new Error("State Flow backup replication requires a non-local Git remote");
|
|
151
|
+
const configuredMerge = git(repositoryRoot, ["config", "--get", `branch.${branch}.merge`], { allowFailure: true });
|
|
152
|
+
if (configuredMerge.status > 1) throw new Error(configuredMerge.stderr || "Cannot inspect State Flow backup branch configuration");
|
|
153
|
+
const ref = configuredMerge.status === 0 && configuredMerge.stdout.trim().length > 0
|
|
154
|
+
? configuredMerge.stdout.trim()
|
|
155
|
+
: branchRef;
|
|
156
|
+
return { remote, ref };
|
|
157
|
+
}
|
|
158
|
+
|
|
137
159
|
function commitCurrentOwnedFiles(repositoryRoot: string, expectedHead: string | undefined): string | undefined {
|
|
138
160
|
const branchRef = currentBranchRef(repositoryRoot);
|
|
139
161
|
if (currentHead(repositoryRoot) !== expectedHead) throw new Error("State Flow backup Git base changed concurrently");
|
|
@@ -183,3 +205,63 @@ function commitCurrentOwnedFiles(repositoryRoot: string, expectedHead: string |
|
|
|
183
205
|
export function backupCurrentStateFlowFiles(repositoryRoot: string): string | undefined {
|
|
184
206
|
return withBackupLock(repositoryRoot, (root) => commitCurrentOwnedFiles(root, currentHead(root)));
|
|
185
207
|
}
|
|
208
|
+
|
|
209
|
+
/** Push the current backup commit to its explicitly configured branch remote without blocking settlement. */
|
|
210
|
+
export function pushCurrentStateFlowBackup(repositoryRoot: string): Promise<{ commit: string; remote: string; ref: string } | undefined> {
|
|
211
|
+
return new Promise((resolvePush, rejectPush) => {
|
|
212
|
+
let root: string;
|
|
213
|
+
let commit: string | undefined;
|
|
214
|
+
let destination: { remote: string; ref: string } | undefined;
|
|
215
|
+
try {
|
|
216
|
+
root = assertRepositoryRoot(repositoryRoot);
|
|
217
|
+
commit = currentHead(root);
|
|
218
|
+
destination = configuredPushDestination(root);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
rejectPush(error);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (commit === undefined || destination === undefined) {
|
|
224
|
+
resolvePush(undefined);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const child = spawn("git", ["-C", root, "push", "--porcelain", "--", destination.remote, `${commit}:${destination.ref}`], {
|
|
228
|
+
detached: process.platform !== "win32",
|
|
229
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
230
|
+
windowsHide: true,
|
|
231
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never" },
|
|
232
|
+
});
|
|
233
|
+
let stderr = "";
|
|
234
|
+
let failure: Error | undefined;
|
|
235
|
+
let settled = false;
|
|
236
|
+
function terminate(error: Error): void {
|
|
237
|
+
failure ??= error;
|
|
238
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
239
|
+
child.stderr?.destroy();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (!child.pid) return;
|
|
243
|
+
try {
|
|
244
|
+
if (process.platform === "win32") child.kill("SIGKILL");
|
|
245
|
+
else process.kill(-child.pid, "SIGKILL");
|
|
246
|
+
} catch {
|
|
247
|
+
// Keep waiting for close: signalling failure is not proof that the process ended.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const timeout = setTimeout(() => terminate(new Error(`Git backup push timed out after ${GIT_TIMEOUT_MS}ms`)), GIT_TIMEOUT_MS);
|
|
251
|
+
function finish(error?: Error): void {
|
|
252
|
+
if (settled) return;
|
|
253
|
+
settled = true;
|
|
254
|
+
clearTimeout(timeout);
|
|
255
|
+
if (error) rejectPush(error);
|
|
256
|
+
else resolvePush({ commit: commit!, ...destination! });
|
|
257
|
+
}
|
|
258
|
+
child.stderr?.on("data", (chunk: Buffer) => { stderr = (stderr + chunk.toString("utf8")).slice(-1000); });
|
|
259
|
+
child.on("error", (error) => {
|
|
260
|
+
failure ??= error;
|
|
261
|
+
if (!child.pid) finish(error);
|
|
262
|
+
});
|
|
263
|
+
child.once("exit", () => { if (failure) child.stderr?.destroy(); });
|
|
264
|
+
child.once("close", (code, endedBy) => finish(failure ?? (code === 0 ? undefined
|
|
265
|
+
: new Error(`Git backup push failed (${endedBy ?? code}): ${redactGitDiagnostic(stderr.trim()) || "no diagnostic output"}`))));
|
|
266
|
+
});
|
|
267
|
+
}
|
|
@@ -56,7 +56,7 @@ READ: Use read_state for concrete scope/history gaps. lazy_navigation lists boun
|
|
|
56
56
|
|
|
57
57
|
WRITE: patch_state is the sole model-authored semantic mutation mechanism. Supply global/cwd/session patches in any combination; all supplied scopes are validated and durably accepted as one atomic transition. Call it alone in an assistant response, then continue only after its acknowledgement.
|
|
58
58
|
|
|
59
|
-
RESPONSE: Ordinary assistant completion needs no finalization patch.
|
|
59
|
+
RESPONSE: Ordinary assistant completion needs no finalization patch. At turn_end, runtime stores the exact accepted answer; empty becomes response "".
|
|
60
60
|
|
|
61
61
|
INTENTS: Keep chosen actions; detail may stay lazy. State refs use {"$ref":"cwd.lazy.plan"} or \`$cwd.lazy.plan\` in text. Resolve only when needed; infer no authority, hydration, execution, or completion. If that resolution proves a dangling state ref, fix/drop it in owning text; never scan for broken refs.
|
|
62
62
|
|
|
@@ -88,10 +88,8 @@ export function finalizedAssistantResponse(message: AgentMessage): string {
|
|
|
88
88
|
if (message.content.some((block) => block.type === "toolCall")) {
|
|
89
89
|
throw new Error("Accepted State Flow response cannot contain a tool call");
|
|
90
90
|
}
|
|
91
|
-
|
|
91
|
+
return message.content
|
|
92
92
|
.filter((block) => block.type === "text")
|
|
93
93
|
.map((block) => block.text)
|
|
94
94
|
.join("");
|
|
95
|
-
if (response.trim().length === 0) throw new Error("Finalized State Flow response must contain non-empty text");
|
|
96
|
-
return response;
|
|
97
95
|
}
|
|
@@ -415,6 +415,17 @@ export class TemporalRuntime {
|
|
|
415
415
|
return { view: reconciledView(), base: captured, provenance };
|
|
416
416
|
}
|
|
417
417
|
|
|
418
|
+
/** Refresh live shared scopes in memory without publishing or advancing private semantic history. */
|
|
419
|
+
refreshShared(): boolean {
|
|
420
|
+
if (!this.view || !this.base) throw new Error("State Flow shared refresh requires a selected temporal runtime");
|
|
421
|
+
const before = this.view;
|
|
422
|
+
const reconciled = this.reconcileSharedDrift(new Set());
|
|
423
|
+
this.view = reconciled.view;
|
|
424
|
+
this.base = reconciled.base;
|
|
425
|
+
this.provenanceByScope = reconciled.provenance;
|
|
426
|
+
return before !== this.view;
|
|
427
|
+
}
|
|
428
|
+
|
|
418
429
|
/** Canonically accept a prepared retained-boundary origin before lifecycle-only persistence. */
|
|
419
430
|
acceptRestoredOrigin(snapshot: Snapshot): RuntimePublication {
|
|
420
431
|
if (!this.restoredOriginPending) throw new Error("State Flow has no prepared restored origin to accept");
|
|
@@ -3,7 +3,7 @@ import { projectRecentTransitionsWithLimit, type RecentTransitionWindow } from "
|
|
|
3
3
|
import { retainedMemoryScopes } from "./memory.ts";
|
|
4
4
|
import type { Snapshot } from "./snapshot.ts";
|
|
5
5
|
import { overlayStates, type ScopedStates, type StateScope } from "./state.ts";
|
|
6
|
-
import type { TransitionBoundary } from "./temporal.ts";
|
|
6
|
+
import type { ScopeRevisions, TransitionBoundary } from "./temporal.ts";
|
|
7
7
|
|
|
8
8
|
export const STATUS_KEY = "state-flow";
|
|
9
9
|
|
|
@@ -24,13 +24,18 @@ export interface StatusDiagnostics {
|
|
|
24
24
|
scopeStates: ScopedStates;
|
|
25
25
|
recent: RecentTransitionWindow;
|
|
26
26
|
historyLimit: number;
|
|
27
|
-
temporal?: { head: TransitionBoundary; historyDepth: number; tailCounts: Record<StateScope, number
|
|
27
|
+
temporal?: { head: TransitionBoundary; historyDepth: number; tailCounts: Record<StateScope, number>; revisions: ScopeRevisions };
|
|
28
28
|
staleArtifacts: readonly StaleArtifactDiagnostic[];
|
|
29
29
|
durableStateError?: string;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
export function
|
|
33
|
-
return
|
|
32
|
+
export function formatScopeRevisionVector(revisions: ScopeRevisions): string {
|
|
33
|
+
return `G${revisions.global}/C${revisions.cwd}/S${revisions.session}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function compactStatus(snapshot: Snapshot, revisions: ScopeRevisions, colorize: Colorize): string | undefined {
|
|
37
|
+
if (!snapshot.config.enabled) return undefined;
|
|
38
|
+
return `${colorize("accent", "state-flow")} ${colorize("dim", formatScopeRevisionVector(revisions))}`;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
function countArtifacts(states: ScopedStates, scope: StateScope): number {
|
|
@@ -62,6 +67,7 @@ export function detailedStatus(snapshot: Snapshot, diagnostics: StatusDiagnostic
|
|
|
62
67
|
`Hot history: unavailable; configured maximum depth ${diagnostics.historyLimit}`,
|
|
63
68
|
"Retained patch tails: unavailable"]
|
|
64
69
|
: [`Temporal head: ${JSON.stringify(temporal.head.id)}; branch-local position ${temporal.head.position}`,
|
|
70
|
+
`Scope revisions: global #${temporal.revisions.global}; CWD #${temporal.revisions.cwd}; session #${temporal.revisions.session}; effective ${formatScopeRevisionVector(temporal.revisions)}`,
|
|
65
71
|
`Hot history: offsets 0..${temporal.historyDepth}; maximum depth ${diagnostics.historyLimit}`,
|
|
66
72
|
`Retained patch tails: global ${temporal.tailCounts.global}; CWD ${temporal.tailCounts.cwd}; session ${temporal.tailCounts.session}`];
|
|
67
73
|
const artifacts = (scope: StateScope) => available ? countArtifacts(diagnostics.scopeStates, scope) : "unknown";
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
// This is a leaf adapter. Core semantics, storage, and inference never depend on it; when
|
|
4
4
|
// pi-telegram is absent or its registry is not ready, registration fails open and retries.
|
|
5
5
|
|
|
6
|
+
import { formatScopeRevisionVector } from "./status.ts";
|
|
7
|
+
import type { ScopeRevisions } from "./temporal.ts";
|
|
8
|
+
|
|
6
9
|
export const STATE_FLOW_TELEGRAM_ID = "@llblab/pi-state-flow";
|
|
7
10
|
|
|
8
11
|
/** Resolve the package export or the compiled sibling-extension layout used in local development. */
|
|
@@ -15,7 +18,9 @@ export function stateFlowTelegramSectionSpecifiers(moduleUrl = import.meta.url):
|
|
|
15
18
|
|
|
16
19
|
export interface StateFlowTelegramSnapshot {
|
|
17
20
|
enabled: boolean;
|
|
21
|
+
/** Legacy branch step retained for existing adapter ports; current runtime ports also supply owner revisions. */
|
|
18
22
|
step: number;
|
|
23
|
+
revisions?: ScopeRevisions;
|
|
19
24
|
bootstrap: boolean;
|
|
20
25
|
startPending: boolean;
|
|
21
26
|
}
|
|
@@ -93,6 +98,8 @@ export interface StateFlowTelegramControlResult {
|
|
|
93
98
|
export interface StateFlowTelegramPort {
|
|
94
99
|
snapshot(): StateFlowTelegramSnapshot;
|
|
95
100
|
state(scope: StateFlowTelegramScope): StateFlowTelegramState;
|
|
101
|
+
/** Optional additive capability; absent legacy ports retain their branch-step presentation. */
|
|
102
|
+
revisions?(): ScopeRevisions;
|
|
96
103
|
canStartNow(): boolean;
|
|
97
104
|
start(): StateFlowTelegramControlResult;
|
|
98
105
|
stop(): StateFlowTelegramControlResult;
|
|
@@ -107,12 +114,14 @@ export interface StateFlowTelegramAdapter {
|
|
|
107
114
|
|
|
108
115
|
/** Main-menu section label doubles as the live status value: the spiral identity is constant, the value is not. */
|
|
109
116
|
export function formatStateFlowSectionLabel(snapshot: StateFlowTelegramSnapshot): string {
|
|
110
|
-
return
|
|
117
|
+
if (!snapshot.enabled) return "🌀 State Flow: off";
|
|
118
|
+
return `🌀 State Flow: ${snapshot.revisions ? formatScopeRevisionVector(snapshot.revisions) : `#${snapshot.step}`}`;
|
|
111
119
|
}
|
|
112
120
|
|
|
113
121
|
/** Shared live value: plain in the button label, monospaced in the submenu state line. */
|
|
114
122
|
function stateFlowLabelValue(snapshot: StateFlowTelegramSnapshot): string {
|
|
115
|
-
|
|
123
|
+
if (!snapshot.enabled) return "off";
|
|
124
|
+
return snapshot.revisions ? formatScopeRevisionVector(snapshot.revisions) : `#${snapshot.step}`;
|
|
116
125
|
}
|
|
117
126
|
|
|
118
127
|
/** Submenu state line: the same identity as the button label, with the live value in monospace. */
|
|
@@ -191,13 +200,18 @@ function renderStateFlowTelegramField(value: unknown): string {
|
|
|
191
200
|
return rendered;
|
|
192
201
|
}
|
|
193
202
|
|
|
194
|
-
export function renderStateFlowRichState(scope: StateFlowTelegramScope,
|
|
195
|
-
const fields =
|
|
203
|
+
export function renderStateFlowRichState(scope: StateFlowTelegramScope, revisions: ScopeRevisions, state: StateFlowTelegramState): StateFlowTelegramRichMessage {
|
|
204
|
+
const fields: Array<keyof StateFlowTelegramState> = scope === "global" || scope === "cwd"
|
|
205
|
+
? ["intents", "contract", "working", "artifacts", "lazy"]
|
|
206
|
+
: ["intents", "contract", "working", "artifacts", "response", "lazy"];
|
|
207
|
+
const revision = scope === "effective"
|
|
208
|
+
? formatScopeRevisionVector(revisions)
|
|
209
|
+
: `#${revisions[scope]}`;
|
|
196
210
|
return {
|
|
197
211
|
blocks: [
|
|
198
212
|
{
|
|
199
213
|
type: "heading",
|
|
200
|
-
text: [`${STATE_FLOW_SCOPE_LABELS[scope]}: `, { type: "code", text:
|
|
214
|
+
text: [`${STATE_FLOW_SCOPE_LABELS[scope]}: `, { type: "code", text: revision }],
|
|
201
215
|
size: 3,
|
|
202
216
|
},
|
|
203
217
|
...fields.map((field) => ({
|
|
@@ -233,7 +247,10 @@ function buildStateFlowTelegramSection(port: StateFlowTelegramPort) {
|
|
|
233
247
|
}
|
|
234
248
|
if (ctx.action === "inspect") {
|
|
235
249
|
if (!isStateFlowTelegramScope(ctx.payload)) throw new Error("Unknown State Flow scope");
|
|
236
|
-
|
|
250
|
+
const state = port.state(ctx.payload);
|
|
251
|
+
const live = port.snapshot();
|
|
252
|
+
const revisions = port.revisions?.() ?? live.revisions ?? { global: live.step, cwd: live.step, session: live.step };
|
|
253
|
+
await ctx.openRich(renderStateFlowRichState(ctx.payload, revisions, state));
|
|
237
254
|
await ctx.answerCallback();
|
|
238
255
|
return "handled" as const;
|
|
239
256
|
}
|
|
@@ -21,6 +21,8 @@ export interface TemporalPatch {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export interface ScopeStream {
|
|
24
|
+
/** Monotonic semantic revision owned by this scope; independent of branch-local boundary positions. */
|
|
25
|
+
revision: number;
|
|
24
26
|
checkpoint: ScopeCheckpoint;
|
|
25
27
|
patches: TemporalPatch[];
|
|
26
28
|
}
|
|
@@ -32,6 +34,7 @@ export interface TemporalState {
|
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
const SCOPES: StateScope[] = ["global", "cwd", "session"];
|
|
37
|
+
export type ScopeRevisions = Record<StateScope, number>;
|
|
35
38
|
|
|
36
39
|
function validateHistoryLimit(limit: number): void {
|
|
37
40
|
if (!Number.isSafeInteger(limit) || limit < 0 || limit > MAX_HISTORY_LIMIT) {
|
|
@@ -69,7 +72,8 @@ function sameBoundary(left: TransitionBoundary, right: TransitionBoundary): bool
|
|
|
69
72
|
export function validateScopeStream(value: unknown, scope: StateScope, historyLimit = DEFAULT_HISTORY_LIMIT): asserts value is ScopeStream {
|
|
70
73
|
validateHistoryLimit(historyLimit);
|
|
71
74
|
if (!SCOPES.includes(scope)) throw new Error("Unknown temporal scope");
|
|
72
|
-
if (!isJsonValue(value) || !isObject(value) || Object.keys(value).sort().join(",") !== "checkpoint,patches"
|
|
75
|
+
if (!isJsonValue(value) || !isObject(value) || Object.keys(value).sort().join(",") !== "checkpoint,patches,revision"
|
|
76
|
+
|| !Number.isSafeInteger(value.revision) || (value.revision as number) < 0
|
|
73
77
|
|| !isObject(value.checkpoint) || Object.keys(value.checkpoint).sort().join(",") !== "state,through"
|
|
74
78
|
|| !Array.isArray(value.patches)) {
|
|
75
79
|
throw new Error("Invalid temporal checkpoint/tail envelope");
|
|
@@ -78,6 +82,7 @@ export function validateScopeStream(value: unknown, scope: StateScope, historyLi
|
|
|
78
82
|
validateBoundary(stream.checkpoint.through);
|
|
79
83
|
validateState(stream.checkpoint.state);
|
|
80
84
|
if (stream.patches.length > historyLimit) throw new Error(`Temporal scope tail exceeds configured history limit ${historyLimit}`);
|
|
85
|
+
if (stream.revision < stream.patches.length) throw new Error("Temporal scope revision predates its retained patch tail");
|
|
81
86
|
let previous = stream.checkpoint.through;
|
|
82
87
|
let state = stream.checkpoint.state;
|
|
83
88
|
const identities = new Set([previous.id]);
|
|
@@ -191,6 +196,7 @@ export function adoptTemporalStreams(scopes: Record<StateScope, ScopeStream>, id
|
|
|
191
196
|
export function createTemporalState(states: ScopedStates, id: string, historyLimit = DEFAULT_HISTORY_LIMIT): TemporalState {
|
|
192
197
|
const through: TransitionBoundary = { id, position: 0, parent: null };
|
|
193
198
|
const stream = (scope: StateScope): ScopeStream => ({
|
|
199
|
+
revision: 0,
|
|
194
200
|
checkpoint: { through: structuredClone(through), state: structuredClone(states[scope]) },
|
|
195
201
|
patches: [],
|
|
196
202
|
});
|
|
@@ -234,7 +240,9 @@ export function selectScopeStreamAtBoundary(stream: ScopeStream, scope: StateSco
|
|
|
234
240
|
throw new Error("Selected State Flow history boundary predates the retained scope checkpoint");
|
|
235
241
|
}
|
|
236
242
|
const selected = structuredClone(stream);
|
|
237
|
-
|
|
243
|
+
const retained = selected.patches.filter(({ transition }) => transition.position <= boundary.position);
|
|
244
|
+
selected.revision -= selected.patches.length - retained.length;
|
|
245
|
+
selected.patches = retained;
|
|
238
246
|
validateScopeStream(selected, scope, historyLimit);
|
|
239
247
|
return selected;
|
|
240
248
|
}
|
|
@@ -250,12 +258,28 @@ export function selectTemporalStateBoundary(view: TemporalState, boundaryId: str
|
|
|
250
258
|
const selected = structuredClone(view);
|
|
251
259
|
selected.lineage = selected.lineage.slice(0, index + 1);
|
|
252
260
|
for (const scope of SCOPES) {
|
|
253
|
-
|
|
261
|
+
const stream = selected.scopes[scope];
|
|
262
|
+
const retained = stream.patches.filter(({ transition }) => transition.position <= target.position);
|
|
263
|
+
stream.revision -= stream.patches.length - retained.length;
|
|
264
|
+
stream.patches = retained;
|
|
254
265
|
}
|
|
255
266
|
validateTemporalState(selected, historyLimit);
|
|
256
267
|
return selected;
|
|
257
268
|
}
|
|
258
269
|
|
|
270
|
+
/** Current independent scope revisions; Effective uses this vector rather than inventing a scalar owner. */
|
|
271
|
+
export function temporalScopeRevisions(view: TemporalState): ScopeRevisions {
|
|
272
|
+
const revisions = {
|
|
273
|
+
global: view.scopes.global.revision,
|
|
274
|
+
cwd: view.scopes.cwd.revision,
|
|
275
|
+
session: view.scopes.session.revision,
|
|
276
|
+
};
|
|
277
|
+
if (Object.values(revisions).some((revision) => !Number.isSafeInteger(revision) || revision < 0)) {
|
|
278
|
+
throw new Error("Invalid State Flow scope revision vector");
|
|
279
|
+
}
|
|
280
|
+
return revisions;
|
|
281
|
+
}
|
|
282
|
+
|
|
259
283
|
/** Lazy scope/effective read at one shared transition boundary, never by local patch count. */
|
|
260
284
|
export function readTemporalState(view: TemporalState, offset = 0, scope?: StateScope, historyLimit = DEFAULT_HISTORY_LIMIT): MaterializedState {
|
|
261
285
|
validateHistoryLimit(historyLimit);
|
|
@@ -298,6 +322,8 @@ export function advanceTemporalState(
|
|
|
298
322
|
const next = structuredClone(view);
|
|
299
323
|
for (const { scope, patch } of changes) {
|
|
300
324
|
const stream = next.scopes[scope];
|
|
325
|
+
if (stream.revision >= Number.MAX_SAFE_INTEGER) throw new Error(`State Flow ${scope} scope revision is exhausted`);
|
|
326
|
+
stream.revision += 1;
|
|
301
327
|
if (historyLimit === 0) {
|
|
302
328
|
stream.checkpoint = { through: structuredClone(boundary), state: apply(scopeAt(stream, head), patch) };
|
|
303
329
|
stream.patches = [];
|
|
@@ -257,8 +257,8 @@ export function stageScopedTransition(
|
|
|
257
257
|
causalBasis: string,
|
|
258
258
|
successfulArtifactReads: Iterable<SuccessfulArtifactRead> = [],
|
|
259
259
|
): StagedScopedTransition {
|
|
260
|
-
if (typeof transition.response !== "string"
|
|
261
|
-
throw new Error("Accepted State Flow response body must be
|
|
260
|
+
if (typeof transition.response !== "string") {
|
|
261
|
+
throw new Error("Accepted State Flow response body must be a string");
|
|
262
262
|
}
|
|
263
263
|
return stageScopedSemanticTransition(
|
|
264
264
|
currentStates,
|
|
@@ -72,22 +72,24 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
|
|
|
72
72
|
- Telegram transport ownership is not semantic queue ownership. Losing the exact transport lock must not erase accepted local queue work or stop valid local Pi dispatch; direct Bot API mutations fail closed until exact direct or follower authority exists.
|
|
73
73
|
- `tmp/telegram/owners.json` is the sole transport-owner authority. Cross-process read/check/write operations serialize transactionally and acquisition, refresh, release, takeover, and irreversible leader work fence the exact owner/epoch. `state.json` and `logs.jsonl` are diagnostics, never routing authority.
|
|
74
74
|
- Threaded Mode has exactly one live leader per bot profile. Followers are real operator-started Pi processes and must authenticate/register over local IPC; Telegram never spawns hidden Pi processes. A live but unreachable owner does not authorize split-brain polling.
|
|
75
|
-
- Local IPC is a trust boundary, not merely a private socket. Unknown, stale, mismatched-generation, or unauthorized requests must not inject prompts, callbacks, API sends, artifacts, liveness, or bindings.
|
|
75
|
+
- Local IPC is a trust boundary, not merely a private socket. Unknown, stale, mismatched-generation, or unauthorized requests must not inject prompts, callbacks, API sends, artifacts, liveness, or bindings. Follower registration identity may prepare a binding, but inbound generation authority is published only after successful preparation for the same generation and current context; pending or failed startup cannot append into a retained old journal. Readiness also binds the active Pi context and supplied session generation. Session refresh awaits binding preparation without re-registering; reusing a context object cannot carry readiness across a session-generation change. Registration requests capture session authority before asynchronous startup and fence every publication/finalization against the current attempt. Stop or supersession invalidates that attempt; obsolete cleanup cannot erase a newer registration or a refreshed context.
|
|
76
76
|
- Protocol compatibility is independent from package version. Registration negotiates protocol version, runtime build, and canonical capabilities before target provisioning or live publication. `durable-follower-admission-v1` gates source forwarding; `queue-handoff-v1` independently gates semantic queue transfer for every participant and is advertised only with exact source/recipient journal-binding composition. `follower.register` and capability-gated restore-only `follower.restoreWorkspace` are bootstrap requests; other requests require exact live-registry generation authority, and `bus.ack` is response-only. `thread-display-mode-v1` gates follower display-setting requests; the leader owns their serialized profile preference and title application.
|
|
77
|
-
- Long-lived timers, pollers, watchers, receivers, heartbeats, background delivery, and deferred dispatch are session-bound. Replacement stops stale activity and makes late work inert;
|
|
77
|
+
- Long-lived timers, pollers, watchers, receivers, heartbeats, background delivery, and deferred dispatch are session-bound. Replacement stops stale activity and makes late work inert; teardown must recheck the captured session generation even when context identity is reused. Same-process handoff may preserve exact profile/target identity but never stale Pi context or cross-profile authority. Participating source observations must hold a reference for the actual read, including pending-mutation/count queries against a stopped worker's retained source; a scoped observation never restores receipt readiness or execution authority. A donor cancellation resumed after remote handoff awaits must also hold a source reference; only the existing exact journal CAS may cancel, never undo accepted recipient custody. Stable source keys do not certify captured callable lifetimes: snapshot prepared worker capabilities at construction and replace them on source-handle renewal without replaying unsettled input. Aborting a durable update generation does not release that `update_id`: replacement replay waits for its actual handler settlement, and effectful handlers use the shared execution fence immediately before commit and after awaited delegation. Admission also rechecks that fence after the default handler returns, before its outcome can settle custody; a stopped handler's ordinary return is not completion authority. Internal clones explicitly carry the hidden fence; reroute forwarding, thread-store mutation, cleanup, and Bot API boundaries retain the originating generation.
|
|
78
78
|
- Runtime state is event-driven reconciliation of local assumptions against Telegram signals, not a complete bot read-model and not permission to query Telegram on every action. Destructive thread cleanup goes through `thread-reconciler` with current proof and leader fencing. Fresh Workspace Thread creation derives its initial Bot API title from the active display mode before issuance; the stable generated `threadName` remains separate from the acknowledged `displayTitle`.
|
|
79
79
|
|
|
80
80
|
### 4.3 Durable Admission And Settlement
|
|
81
81
|
|
|
82
82
|
- Admission is journal-first: validate and persist the complete `getUpdates` response before one monotonic offset commit, then signal an independent worker without awaiting semantic execution. Missing cursor with a non-empty journal, malformed/foreign authority, or capacity exhaustion fails closed. “Durable” means process-crash recovery after atomic rename, not unflushed host/kernel/filesystem/device/power-loss survival.
|
|
83
|
+
- Storage cutovers must reconcile actual consumer locations before correcting path adapters. Never normalize a relative historical reference into new authority or treat equal reference strings / empty canonical storage as source completeness. Exact-path preflight is lexical only; physical identity, historical coverage, writer closure and migration remain separate proofs.
|
|
83
84
|
- Workspace mutations acquire cross-process admission before their shared process-local gate and hold it through asynchronous API work and durable settlement. Topic lifecycle, complete unbound/reroute target handling, manual disconnect, and session-restart cleanup use profile-wide scope; either retained retirement-fence phase rejects them before state access. Cleanup scope spans intent publication, target mutation, persistence, and transport release. Detached reconciliation that mutates Thread state must reacquire fresh profile admission through the same gate; it cannot inherit a caller lease that ended before its timer runs. A live operation ID has one process-local caller: concurrent reuse is rejected before lease acquisition, while retry after the caller exits may resume exact durable authority.
|
|
84
|
-
- Workspace retirement is capacity-pressure-only. Elapsed time and heartbeat silence never trigger deletion; only complete `A`–`Z` exhaustion may propose the oldest continuously proven inactive, fully unprotected binding. Exact deletion, durable retirement, and fence completion precede slot reuse.
|
|
85
|
+
- Workspace retirement is capacity-pressure-only. Elapsed time and heartbeat silence never trigger deletion; only complete `A`–`Z` exhaustion may propose the oldest continuously proven inactive, fully unprotected binding. Exact deletion, durable retirement, and fence completion precede slot reuse. Authorized demand-driven rotation retries one failed fresh allocation after retirement; restore-only follower startup never evicts. Release ordinary registration/provisioning leases before acquiring the destructive fence, retain the shared mutation gate across retirement, and validate the exact permit immediately before one non-retried deletion. Persist an exact method/target-matched rejection before withdrawing its intent; release that fence only after durable withdrawal, retaining the binding. A later attempt requires fresh operation authority. Protection reads must never repair, quarantine, or reset journals. Non-destructive owner detachment must atomically retain one exact Workspace binding and its letter while removing only its uniquely matched owner record and stamping first inactivity; it never manufactures Thread-deletion evidence or clears accepted work. Retained prune observations are bounded, non-routing and registration/profile/epoch/runtime-fenced. One unfinished preservation operation retains its admission identity across fresh-PID-proof retries; it can never become deletion authority. Leader quit requires completed delivery/polling/worker teardown under the captured session generation, profile and epoch; reload/new/resume/fork never establish inactivity. Unknown deletion outcomes retain their fence; only confirmed durable completion permits reuse.
|
|
85
86
|
- Foreign forwarding settles as `accepted`, `retryable`, or `terminal-rejected`. Only an authenticated acknowledgement carrying the expected `deliveryId` and `sourceUpdateId` releases leader journal authority. Negative, missing, stale, mismatched, or capacity-failed settlement remains durable; callback error answers are side effects only.
|
|
86
87
|
- A forwarding delivery id is stable across registration replacement and derives from envelope kind, source `update_id`, and stable recipient binding. Runtime instance and registration generation remain separate attempt fences. Persisted message ownership carries the stable binding so replay can rebind only to its current authenticated registration.
|
|
87
|
-
- A queued receipt persists its acquiring runtime instance, OS pid/process-birth identity, session generation, acquisition id, and acquisition time. Only exact authority may settle or discard it. Same-process session replacement may reconstruct the claim and the original process may settle after transport ownership moves; a foreign process may neither replay nor settle it through generic removal or a copied acquisition id.
|
|
88
|
-
- Startup and elapsed time are not owner-death proof; queued authority has no time lease. Dead-owner cleanup groups the complete receipt and transactionally rechecks pid liveness plus process-birth identity: only an absent PID or mismatched stable Linux/macOS birth proof discards all session-owned sources without replay; a matching proof is `alive`, while Windows or inaccessible birth metadata is `unverifiable`, and both non-dead outcomes keep authority queued. The live-transfer contract is authenticated offer → exact-generation bounded payload staging → recipient CAS acceptance → exact receipt-and-owner ACK → donor removal → recipient readiness. The offer freezes donor settlement/recovery; controls rebuild local closures; negative/mismatched pre-acceptance ACK cancels only an unaccepted offer and retains donor work; a lost post-acceptance ACK cannot cancel recipient authority and leaves donor memory frozen for explicit reconciliation.
|
|
88
|
+
- A queued receipt persists its acquiring runtime instance, OS pid/process-birth identity, session generation, acquisition id, and acquisition time. Only exact authority may settle or discard it. Cached presence is not current execution proof: prepared custody must revalidate the exact queued owner/group without recovery and refuse offers or uncertain reads. Completion requires an exact removal acknowledgement, never merely `!ready`; a retained acknowledgement permits local cleanup only, not replay. Same-process session replacement may reconstruct the claim and the original process may settle after transport ownership moves; a foreign process may neither replay nor settle it through generic removal or a copied acquisition id.
|
|
89
|
+
- Startup and elapsed time are not owner-death proof; queued authority has no time lease. Dead-owner cleanup groups the complete receipt and transactionally rechecks pid liveness plus process-birth identity: only an absent PID or mismatched stable Linux/macOS birth proof discards all session-owned sources without replay; a matching proof is `alive`, while Windows or inaccessible birth metadata is `unverifiable`, and both non-dead outcomes keep authority queued. Under actual `A`–`Z` allocation pressure, retirement may invoke that journal-owned CAS only for a current inactive binding after complete strict source inspection, no local work/live owner/delivery authority, whole unoffered receipt groups, and a preflight proving every grouped owner dead. It must then recapture all protection before preparing deletion; partial progress never grants deletion authority. The live-transfer contract is authenticated offer → exact-generation bounded payload staging → recipient CAS acceptance → exact receipt-and-owner ACK → donor removal → recipient readiness. The offer freezes donor settlement/recovery; controls rebuild local closures; negative/mismatched pre-acceptance ACK cancels only an unaccepted offer and retains donor work; a lost post-acceptance ACK cannot cancel recipient authority and leaves donor memory frozen for explicit reconciliation.
|
|
89
90
|
- Execution failures persist bounded diagnostics and attempt state as `retry-wait`, except that an exact Telegram HTTP 400 stale/deleted-thread API failure with a proven `{chatId, threadId}` terminally settles the currently executing source after best-effort shared binding invalidation. Automatic retry continues indefinitely with exponential `1s → 2s → 4s → 8s → 16s → 32s → 60s` delay capped at 60 seconds; later independent updates continue draining, durable authority is never silently discarded, and legacy `failed` entries resume automatically at startup. Snapshot-plus-segment journals compact only after 256 unapplied revisions or 4 MiB; snapshot-first cleanup tolerates redundant segments, and empty authority may atomically rebind bot/profile identity. Missing snapshots left by the retired broad temp cleanup rebuild only from a complete provably empty segment chain, while revisionless snapshots may recover from a validated later segment predecessor; otherwise the snapshot and segments move atomically under `tmp/telegram/recovery/` before a fresh journal is published and startup continues with informational recovery evidence.
|
|
90
|
-
-
|
|
91
|
+
- Business connection chats are a separate namespace even when their chat/message IDs match bot-chat IDs. Default DM routing must never infer private-queue deletion intent from `deleted_business_messages`; raw companion handlers remain separate owners.
|
|
92
|
+
- An unresolved reaction delays only the exact governed queue item identified by chat/message sources, not unrelated queue work. An explicit immutable `preApprovalExcluded: true` cannot govern accepted work, even after re-pairing; missing or false exclusion evidence must not bypass the dependency guard. Prepared v3 drain may dispose of excluded pending input only through journal-owned `removeExcluded`, never generic raw completion; mixed requests containing non-excluded input must fail atomically. Queue receipt publication follows in-memory append and precedes dispatch request; receipt-bearing turns remain queued until every exact source commits.
|
|
91
93
|
- The detailed implementation and release gates live in [`docs/architecture.md`](./docs/architecture.md), [`docs/multi-instance-bus.md`](./docs/multi-instance-bus.md), and [`BACKLOG.md`](./BACKLOG.md).
|
|
92
94
|
|
|
93
95
|
### 4.4 Queue, Delivery, And User Surfaces
|
|
@@ -99,7 +101,7 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
|
|
|
99
101
|
- `preview` owns streaming lifecycle only, not assistant rendering. Finalization waits for active preview flushes and must not issue pre/post-final draft-clear calls that create transient Telegram draft UI. Turns that already answer as one atomic reply (voice replies, Guest Mode queries) never stream previews.
|
|
100
102
|
- Native `sendChatAction(typing)` is the automatic activity signal for unsettled agent and compaction work while Telegram transport is authorized. Extension-owned blocking UI prompts pause it and completion resumes it while either work owner remains active. Do not invent extra in-chat work indicators or emit activity for startup/connect/reload/recovery alone.
|
|
101
103
|
- Public activity handlers and connected companion delivery are asynchronous, target-bound, generation-fenced surfaces. Connected companion projection has no independent opt-out: disconnect or authority loss is its boundary. Token deltas, hidden reasoning, unknown sources, and stale authority never enter public projection.
|
|
102
|
-
- Thread display defaults to the profile-scoped Letters strategy, with Names, Directory Snake, and Directory Title as the other automatic choices; Names projects the generated dictionary name for the slot. Unsupported retained display keys resolve to Letters without rewriting persisted configuration. A durable manual Thread display name retained on its Workspace binding overrides any automatic projection until exact reset; keep generated/recovery identity separate from manual and acknowledged display fields. UI labels, emoji semantics, navigation, settings controls, callback namespaces, voice behavior, command templates, and assistant markup follow the linked `/docs` contracts. Generated human-readable prompt-button labels use `emoji + space + text`; emoji-free text is only a reasoned no-semantic-marker fallback. Non-spatial generated controls default to top-level vertical cells, with nested rows reserved for unmistakably compact peers. Do not restate other evolving UI details here.
|
|
104
|
+
- Thread display defaults to the profile-scoped Letters strategy, with Names, Directory Snake, and Directory Title as the other automatic choices; Names projects the generated dictionary name for the slot. Unsupported retained display keys resolve to Letters without rewriting persisted configuration. A durable manual Thread display name retained on its Workspace binding overrides any automatic projection until exact reset; keep generated/recovery identity separate from manual and acknowledged display fields. After leader startup, automatic display contraction waits one follower-staleness window so election and follower re-registration cannot briefly remove and restore an acknowledged same-cwd suffix; stop/start generation cancels stale reconciliation. UI labels, emoji semantics, navigation, settings controls, callback namespaces, voice behavior, command templates, and assistant markup follow the linked `/docs` contracts. Generated human-readable prompt-button labels use `emoji + space + text`; emoji-free text is only a reasoned no-semantic-marker fallback. Non-spatial generated controls default to top-level vertical cells, with nested rows reserved for unmistakably compact peers. Do not restate other evolving UI details here.
|
|
103
105
|
|
|
104
106
|
## 5. Domain Ownership Index
|
|
105
107
|
|