@aeondave/pi-persona 1.17.3 → 1.17.5
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/AGENTS.md +57 -36
- package/CHANGELOG.md +106 -1
- package/README.md +32 -16
- package/docs/ARCHITECTURE.md +199 -48
- package/docs/COLLABORATION.md +110 -0
- package/docs/EXPERIENCE.md +33 -8
- package/docs/README.md +12 -0
- package/docs/REFERENCE.md +65 -14
- package/docs/STRATEGIES.md +6 -0
- package/package.json +12 -6
- package/src/agents/agent.ts +6 -0
- package/src/bridge.ts +33 -23
- package/src/bus/broker/client.ts +151 -30
- package/src/bus/broker/host.ts +130 -11
- package/src/bus/broker/messages.ts +35 -5
- package/src/bus/contact-transport.ts +8 -0
- package/src/bus/contact.ts +118 -40
- package/src/bus/peers.ts +23 -6
- package/src/bus/result-reader.ts +88 -0
- package/src/core/brief.ts +14 -4
- package/src/core/capabilities.ts +1 -1
- package/src/core/companion.ts +64 -0
- package/src/core/leg-thinking.ts +57 -0
- package/src/core/ownership.ts +1 -2
- package/src/core/pi-compat.ts +5 -12
- package/src/core/session-runs.ts +1 -1
- package/src/core/types.ts +1 -1
- package/src/engine/adapter.ts +100 -20
- package/src/engine/async.ts +53 -11
- package/src/engine/child.ts +11 -2
- package/src/engine/inproc.ts +98 -47
- package/src/engine/result-grant.ts +62 -0
- package/src/engine/session-tools.ts +6 -0
- package/src/engine/stream.ts +6 -0
- package/src/engine/worktree.ts +17 -7
- package/src/exocom/collaboration-delivery.ts +29 -0
- package/src/exocom/collaboration.ts +148 -0
- package/src/exocom/envelope.ts +43 -4
- package/src/exocom/gate.ts +7 -2
- package/src/exocom/install.ts +45 -17
- package/src/exocom/ledger.ts +58 -8
- package/src/exocom/plane.ts +32 -1
- package/src/exocom/profile.ts +40 -0
- package/src/exocom/registry.ts +5 -0
- package/src/exocom/status.ts +11 -1
- package/src/exocom/write-guard.ts +7 -0
- package/src/extension/broker-host.ts +61 -9
- package/src/extension/companion.ts +38 -0
- package/src/extension/engine.ts +40 -29
- package/src/extension/hooks.ts +954 -931
- package/src/extension/identity.ts +41 -75
- package/src/extension/memory-owner.ts +54 -0
- package/src/extension/mind-worker.ts +129 -0
- package/src/extension/results.ts +138 -0
- package/src/extension/shared.ts +17 -0
- package/src/extension.ts +2922 -2849
- package/src/loader.ts +11 -3
- package/src/orchestration/agent-attempt.ts +15 -0
- package/src/orchestration/flow-journal.ts +7 -1
- package/src/orchestration/flow-run.ts +125 -19
- package/src/orchestration/flow.ts +11 -0
- package/src/orchestration/roster.ts +71 -23
- package/src/orchestration/sdk.ts +61 -19
- package/src/orchestration/types.ts +10 -0
- package/src/persona/model-participants.ts +20 -7
- package/src/persona/orchestrate.ts +6 -7
- package/src/persona/persona.ts +1 -1
- package/src/results/archive.ts +281 -0
- package/src/results/record.ts +166 -0
- package/src/results/retrieval.ts +142 -0
- package/src/results/types.ts +128 -0
- package/src/telemetry/contract.ts +1 -1
- package/src/tools/council.ts +40 -23
- package/src/tools/delegate-tool.ts +50 -14
- package/src/tools/delegate.ts +13 -6
- package/src/tools/exocom-collaboration-input.ts +168 -0
- package/src/tools/exocom-collaboration.ts +315 -0
- package/src/tools/exocom-profile.ts +102 -0
- package/src/tools/exocom.ts +20 -3
- package/src/tools/flow.ts +24 -8
- package/src/tools/intercom-tool.ts +98 -14
- package/src/tools/models.ts +15 -0
- package/src/tools/monitor.ts +20 -1
- package/src/tools/read-result.ts +52 -0
- package/src/tools/timer.ts +23 -2
- package/src/ui/activity-rows.ts +34 -0
- package/src/ui/agent-overlay.ts +23 -13
- package/src/ui/agent-tree.ts +67 -6
- package/src/ui/literal-tool-result.ts +1 -1
- package/src/ui/model-picker.ts +1 -1
- package/src/ui/orchestration-launch.ts +49 -0
- package/src/ui/session-cards.ts +191 -14
- package/src/ui/strategy-leg.ts +40 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/** Voluntary peer work. Pure domain rules; claims and persistence stay in the ledger transaction. */
|
|
2
|
+
import { writeSetPathError } from "../core/ownership.ts";
|
|
3
|
+
import { isSemanticFrame, type ExocomSemanticFrame, type ExocomCollaborationFrame, type ExocomOffer, type ExocomProgress, type ExocomPublish, type ExocomTaskCreate, type ExocomTaskFinish, type ExocomTaskTake } from "./envelope.ts";
|
|
4
|
+
|
|
5
|
+
export interface CollaborationTask {
|
|
6
|
+
created: ExocomTaskCreate;
|
|
7
|
+
taken?: ExocomTaskTake;
|
|
8
|
+
progress?: ExocomProgress;
|
|
9
|
+
finished?: ExocomTaskFinish;
|
|
10
|
+
/** Loss of the owning registry lease is visible and never permits automatic pickup. */
|
|
11
|
+
stale?: true;
|
|
12
|
+
/** Receiver clock at the first lease-loss reconciliation, or release timestamp. */
|
|
13
|
+
staleAt?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface CollaborationState { tasks: CollaborationTask[]; offers: ExocomOffer[]; evidence: ExocomPublish[]; }
|
|
16
|
+
export const COLLABORATION_LIMITS = { tasks: 64, offers: 64, evidence: 64, maxBytes: 131_072 } as const;
|
|
17
|
+
export function emptyCollaboration(): CollaborationState { return { tasks: [], offers: [], evidence: [] }; }
|
|
18
|
+
export function collaborationStatus(task: CollaborationTask): "open" | "taken" | "done" | "abandoned" | "stale" {
|
|
19
|
+
return task.finished?.outcome ?? (task.stale ? "stale" : task.taken ? "taken" : "open");
|
|
20
|
+
}
|
|
21
|
+
export function offerStatus(offer: ExocomOffer, now: number): "open" | "expired" {
|
|
22
|
+
return Date.parse(offer.expires_at) <= now ? "expired" : "open";
|
|
23
|
+
}
|
|
24
|
+
/** Time expiry is authoritative even after the exact task generation becomes unavailable. */
|
|
25
|
+
export function collaborationOfferStatus(offer: ExocomOffer, tasks: readonly CollaborationTask[], now: number): "open" | "expired" | "stale" {
|
|
26
|
+
if (offerStatus(offer, now) === "expired") return "expired";
|
|
27
|
+
const task = tasks.find((candidate) => candidate.created.work_key === offer.work_key && candidate.created.msg_id === offer.task_id);
|
|
28
|
+
return task && collaborationStatus(task) === "open" ? "open" : "stale";
|
|
29
|
+
}
|
|
30
|
+
function bounded(state: CollaborationState, reserveLifecycle = true): boolean {
|
|
31
|
+
// Local ledger lifecycle events are unsigned. Reserve their largest identity metadata and a
|
|
32
|
+
// minimal terminal note, as well as lease loss, before admitting optional prose. A full note
|
|
33
|
+
// can hit quota; taking work, finishing with a short note and release must remain available.
|
|
34
|
+
const reserved = { ...state, tasks: state.tasks.map((task) => {
|
|
35
|
+
if (task.finished) return task;
|
|
36
|
+
const future = { work_key: task.created.work_key, task_id: task.created.msg_id, from_session: "x".repeat(128),
|
|
37
|
+
from_name: "界".repeat(256), msg_id: "x".repeat(128), ts: "9999-12-31T23:59:59.999Z" };
|
|
38
|
+
return { ...task, stale: true, staleAt: -Number.MAX_VALUE,
|
|
39
|
+
taken: task.taken ?? { ...future, kind: "task_take" }, finished: { ...future, kind: "task_finish", outcome: "abandoned", note: "x" } };
|
|
40
|
+
}) };
|
|
41
|
+
return state.tasks.length <= COLLABORATION_LIMITS.tasks && state.offers.length <= COLLABORATION_LIMITS.offers
|
|
42
|
+
&& state.evidence.length <= COLLABORATION_LIMITS.evidence && Buffer.byteLength(JSON.stringify(reserveLifecycle ? reserved : state), "utf8") <= COLLABORATION_LIMITS.maxBytes;
|
|
43
|
+
}
|
|
44
|
+
/** Retained facts outlive the rolling transport-id window; they must never acquire duplicate identities. */
|
|
45
|
+
export function retainedCollaborationEvent(state: CollaborationState, msgId: string): ExocomSemanticFrame | undefined {
|
|
46
|
+
for (const task of state.tasks) {
|
|
47
|
+
for (const event of [task.created, task.taken, task.progress, task.finished]) if (event?.msg_id === msgId) return event;
|
|
48
|
+
}
|
|
49
|
+
return state.offers.find((event) => event.msg_id === msgId) ?? state.evidence.find((event) => event.msg_id === msgId);
|
|
50
|
+
}
|
|
51
|
+
type Transition = { ok: true; state: CollaborationState } | { ok: false; error: string };
|
|
52
|
+
function transition(state: CollaborationState, event: ExocomCollaborationFrame | ExocomProgress, reserveLifecycle: boolean): Transition {
|
|
53
|
+
if (event.kind === "progress" && event.task_id === undefined) return { ok: true, state };
|
|
54
|
+
const task = state.tasks.find((value) => value.created.work_key === event.work_key);
|
|
55
|
+
let next = state;
|
|
56
|
+
if (event.kind === "task_create") {
|
|
57
|
+
if (task) return { ok: false, error: "task work_key already exists" };
|
|
58
|
+
for (const path of event.write_set) {
|
|
59
|
+
const error = writeSetPathError(path);
|
|
60
|
+
if (error) return { ok: false, error: `task write_set path is ${error}` };
|
|
61
|
+
}
|
|
62
|
+
next = { ...state, tasks: [...state.tasks, { created: event }] };
|
|
63
|
+
} else {
|
|
64
|
+
if (!task) return { ok: false, error: "unknown collaboration task" };
|
|
65
|
+
if (event.task_id !== task.created.msg_id) return { ok: false, error: "task_id does not match this work key's retained generation" };
|
|
66
|
+
if (event.kind === "publish") next = { ...state, evidence: [...state.evidence, event] };
|
|
67
|
+
else if (event.kind === "offer") {
|
|
68
|
+
if (state.offers.some((offer) => offer.offer_id === event.offer_id)) return { ok: false, error: "offer_id already exists" };
|
|
69
|
+
if (event.to_session === event.from_session) return { ok: false, error: "offer cannot target self" };
|
|
70
|
+
if (collaborationStatus(task) !== "open") return { ok: false, error: "offers require an open task" };
|
|
71
|
+
next = { ...state, offers: [...state.offers, event] };
|
|
72
|
+
} else {
|
|
73
|
+
const status = collaborationStatus(task);
|
|
74
|
+
if (event.kind === "task_take") {
|
|
75
|
+
if (status !== "open") return { ok: false, error: `task is ${status}; pickup refused` };
|
|
76
|
+
next = { ...state, tasks: state.tasks.map((value) => value === task ? { ...task, taken: event } : value) };
|
|
77
|
+
} else {
|
|
78
|
+
if (status !== "taken" || task.taken?.from_session !== event.from_session) return { ok: false, error: "only the active task taker may report progress or finish" };
|
|
79
|
+
if (event.kind === "progress" && Date.parse(event.ts) < Date.parse(task.progress?.ts ?? task.taken.ts)) return { ok: false, error: "task progress predates its latest observation" };
|
|
80
|
+
const updated = event.kind === "progress" ? { ...task, progress: event } : { ...task, finished: event };
|
|
81
|
+
next = { ...state, tasks: state.tasks.map((value) => value === task ? updated : value) };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return bounded(next, reserveLifecycle) ? { ok: true, state: next } : { ok: false, error: "collaboration retention budget reached; shorten optional report text" };
|
|
86
|
+
}
|
|
87
|
+
export function applyCollaborationEvent(state: CollaborationState, event: ExocomCollaborationFrame | ExocomProgress): Transition {
|
|
88
|
+
return transition(state, event, true);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function staleCollaboration(state: CollaborationState, isLive: (session: string) => boolean, now: number): CollaborationState {
|
|
92
|
+
return { ...state, tasks: state.tasks.map((task) => task.finished || task.stale || isLive(task.taken?.from_session ?? task.created.from_session)
|
|
93
|
+
? task : { ...task, stale: true, staleAt: now }) };
|
|
94
|
+
}
|
|
95
|
+
export function releaseCollaboration(state: CollaborationState, session: string, workKey: string, now: number): CollaborationState {
|
|
96
|
+
return { ...state, tasks: state.tasks.map((task) => task.created.work_key === workKey && !task.finished && !task.stale
|
|
97
|
+
&& (task.taken?.from_session ?? task.created.from_session) === session ? { ...task, stale: true, staleAt: now } : task) };
|
|
98
|
+
}
|
|
99
|
+
export function pruneCollaboration(state: CollaborationState, now: number, ttlMs: number, isLive: (session: string) => boolean): CollaborationState {
|
|
100
|
+
const reconciled = staleCollaboration(state, isLive, now);
|
|
101
|
+
const tasks = reconciled.tasks.filter((task) => {
|
|
102
|
+
const terminalAt = task.finished ? Date.parse(task.finished.ts) : task.staleAt;
|
|
103
|
+
return terminalAt === undefined || now - terminalAt <= ttlMs;
|
|
104
|
+
});
|
|
105
|
+
const retained = new Set(tasks.map((task) => task.created.work_key));
|
|
106
|
+
return {
|
|
107
|
+
tasks,
|
|
108
|
+
offers: state.offers.filter((offer) => retained.has(offer.work_key) && now - Date.parse(offer.expires_at) <= ttlMs),
|
|
109
|
+
evidence: state.evidence.filter((item) => retained.has(item.work_key) && now - Date.parse(item.ts) <= ttlMs),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Reconstruct snapshots through the same rules. No malformed ownership record is silently skipped. */
|
|
114
|
+
export function parseCollaboration(raw: unknown): CollaborationState | undefined {
|
|
115
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
116
|
+
const value = raw as CollaborationState;
|
|
117
|
+
if (!Array.isArray(value.tasks) || !Array.isArray(value.offers) || !Array.isArray(value.evidence) || !bounded(value)) return undefined;
|
|
118
|
+
let state = emptyCollaboration();
|
|
119
|
+
const seen = new Set<string>();
|
|
120
|
+
function replay(event: unknown, kind: string): boolean {
|
|
121
|
+
if (!isSemanticFrame(event) || event.kind !== kind || seen.has(event.msg_id)) return false;
|
|
122
|
+
if (!(event.kind === "task_create" || event.kind === "task_take" || event.kind === "task_finish" || event.kind === "progress" || event.kind === "offer" || event.kind === "publish")) return false;
|
|
123
|
+
// Definitions must precede historical offers. Replay checks physical bounds; reserve
|
|
124
|
+
// future lifecycle only on the final state, once completed tasks are terminal again.
|
|
125
|
+
const applied = transition(state, event, false);
|
|
126
|
+
if (!applied.ok) return false;
|
|
127
|
+
seen.add(event.msg_id);
|
|
128
|
+
state = applied.state;
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
// All definitions precede offers/evidence; a finished task's historical offer is valid.
|
|
132
|
+
for (const task of value.tasks) {
|
|
133
|
+
if (!task || typeof task !== "object" || !replay(task.created, "task_create")) return undefined;
|
|
134
|
+
}
|
|
135
|
+
for (const offer of value.offers) if (!replay(offer, "offer")) return undefined;
|
|
136
|
+
for (const task of value.tasks) {
|
|
137
|
+
for (const [event, kind] of [[task.taken, "task_take"], [task.progress, "progress"], [task.finished, "task_finish"]] as const) {
|
|
138
|
+
if (event !== undefined && (event.work_key !== task.created.work_key || !replay(event, kind))) return undefined;
|
|
139
|
+
}
|
|
140
|
+
if (task.stale !== undefined && task.stale !== true) return undefined;
|
|
141
|
+
if (task.stale) {
|
|
142
|
+
if (task.finished || typeof task.staleAt !== "number" || !Number.isFinite(task.staleAt)) return undefined;
|
|
143
|
+
state = releaseCollaboration(state, task.taken?.from_session ?? task.created.from_session, task.created.work_key, task.staleAt);
|
|
144
|
+
} else if (task.staleAt !== undefined) return undefined;
|
|
145
|
+
}
|
|
146
|
+
for (const evidence of value.evidence) if (!replay(evidence, "publish")) return undefined;
|
|
147
|
+
return bounded(state) ? state : undefined;
|
|
148
|
+
}
|
package/src/exocom/envelope.ts
CHANGED
|
@@ -31,13 +31,20 @@ export interface ExocomAnswer {
|
|
|
31
31
|
}
|
|
32
32
|
export interface ExocomProgress {
|
|
33
33
|
kind: "progress"; work_key: string; from_session: string; from_name: string;
|
|
34
|
-
note: string; msg_id: string; ts: string; signature?: string;
|
|
34
|
+
note: string; msg_id: string; ts: string; signature?: string; task_id?: string;
|
|
35
35
|
}
|
|
36
36
|
export interface ExocomRelease {
|
|
37
37
|
kind: "release"; work_key: string; from_session: string; from_name: string;
|
|
38
38
|
msg_id: string; ts: string; signature?: string;
|
|
39
39
|
}
|
|
40
|
-
|
|
40
|
+
interface CollaborationFrame { work_key: string; from_session: string; from_name: string; msg_id: string; ts: string; signature?: string; }
|
|
41
|
+
export interface ExocomTaskCreate extends CollaborationFrame { kind: "task_create"; description: string; write_set: string[]; }
|
|
42
|
+
export interface ExocomTaskTake extends CollaborationFrame { kind: "task_take"; task_id: string; }
|
|
43
|
+
export interface ExocomTaskFinish extends CollaborationFrame { kind: "task_finish"; task_id: string; outcome: "done" | "abandoned"; note: string; }
|
|
44
|
+
export interface ExocomOffer extends CollaborationFrame { kind: "offer"; task_id: string; offer_id: string; to_session?: string; expires_at: string; }
|
|
45
|
+
export interface ExocomPublish extends CollaborationFrame { kind: "publish"; task_id: string; title: string; excerpt: string; }
|
|
46
|
+
export type ExocomCollaborationFrame = ExocomTaskCreate | ExocomTaskTake | ExocomTaskFinish | ExocomOffer | ExocomPublish;
|
|
47
|
+
export type ExocomSemanticFrame = ExocomClaim | ExocomAsk | ExocomAnswer | ExocomProgress | ExocomRelease | ExocomCollaborationFrame;
|
|
41
48
|
export type ExocomFrame = ExocomMessage | ExocomBye | ExocomAck | ExocomNack | ExocomSemanticFrame;
|
|
42
49
|
|
|
43
50
|
const str = (v: unknown): v is string => typeof v === "string";
|
|
@@ -73,10 +80,27 @@ export function isSemanticFrame(v: unknown): v is ExocomSemanticFrame {
|
|
|
73
80
|
case "progress":
|
|
74
81
|
return token(o.work_key) && token(o.from_session) && metadata(o.from_name, 256)
|
|
75
82
|
&& bounded(o.note, 4_096) && token(o.msg_id) && timestamp(o.ts)
|
|
76
|
-
&& optionalBounded(o.signature, 512);
|
|
83
|
+
&& optionalBounded(o.signature, 512) && (o.task_id === undefined || token(o.task_id));
|
|
77
84
|
case "release":
|
|
78
85
|
return token(o.work_key) && token(o.from_session) && metadata(o.from_name, 256)
|
|
79
86
|
&& token(o.msg_id) && timestamp(o.ts) && optionalBounded(o.signature, 512);
|
|
87
|
+
case "task_create":
|
|
88
|
+
case "task_take":
|
|
89
|
+
case "task_finish":
|
|
90
|
+
case "offer":
|
|
91
|
+
case "publish": {
|
|
92
|
+
if (!token(o.work_key) || !token(o.from_session) || !metadata(o.from_name, 256)
|
|
93
|
+
|| !token(o.msg_id) || !timestamp(o.ts) || !optionalBounded(o.signature, 512)) return false;
|
|
94
|
+
if (o.kind === "task_create") return bounded(o.description, 2_048) && Array.isArray(o.write_set)
|
|
95
|
+
&& (o.write_set.length === 0 || writeSet(o.write_set));
|
|
96
|
+
if (!token(o.task_id)) return false;
|
|
97
|
+
if (o.kind === "task_finish") return (o.outcome === "done" || o.outcome === "abandoned") && bounded(o.note, 4_096);
|
|
98
|
+
if (o.kind === "offer") return token(o.offer_id) && (o.to_session === undefined || token(o.to_session))
|
|
99
|
+
&& timestamp(o.expires_at) && Date.parse(o.expires_at) > Date.parse(o.ts)
|
|
100
|
+
&& Date.parse(o.expires_at) - Date.parse(o.ts) <= 86_400_000;
|
|
101
|
+
if (o.kind === "publish") return bounded(o.title, 160) && bounded(o.excerpt, 4_096) && Buffer.byteLength(o.excerpt, "utf8") <= 8_192;
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
80
104
|
default:
|
|
81
105
|
return false;
|
|
82
106
|
}
|
|
@@ -102,6 +126,11 @@ export function isExocomFrame(v: unknown): v is ExocomFrame {
|
|
|
102
126
|
case "answer":
|
|
103
127
|
case "progress":
|
|
104
128
|
case "release":
|
|
129
|
+
case "task_create":
|
|
130
|
+
case "task_take":
|
|
131
|
+
case "task_finish":
|
|
132
|
+
case "offer":
|
|
133
|
+
case "publish":
|
|
105
134
|
return isSemanticFrame(v);
|
|
106
135
|
default: return false;
|
|
107
136
|
}
|
|
@@ -127,9 +156,19 @@ export function frameSigningPayload(frame: ExocomFrame): string {
|
|
|
127
156
|
frame.ok, frame.evidence, frame.ts]);
|
|
128
157
|
case "progress":
|
|
129
158
|
return JSON.stringify([frame.kind, frame.msg_id, frame.work_key, frame.from_session, frame.from_name,
|
|
130
|
-
frame.note, frame.ts]);
|
|
159
|
+
frame.note, frame.ts, frame.task_id ?? null]);
|
|
131
160
|
case "release":
|
|
132
161
|
return JSON.stringify([frame.kind, frame.msg_id, frame.work_key, frame.from_session, frame.from_name, frame.ts]);
|
|
162
|
+
case "task_create":
|
|
163
|
+
return JSON.stringify([frame.kind, frame.msg_id, frame.work_key, frame.from_session, frame.from_name, frame.description, frame.write_set, frame.ts]);
|
|
164
|
+
case "task_take":
|
|
165
|
+
return JSON.stringify([frame.kind, frame.msg_id, frame.work_key, frame.task_id, frame.from_session, frame.from_name, frame.ts]);
|
|
166
|
+
case "task_finish":
|
|
167
|
+
return JSON.stringify([frame.kind, frame.msg_id, frame.work_key, frame.task_id, frame.from_session, frame.from_name, frame.outcome, frame.note, frame.ts]);
|
|
168
|
+
case "offer":
|
|
169
|
+
return JSON.stringify([frame.kind, frame.msg_id, frame.offer_id, frame.work_key, frame.task_id, frame.from_session, frame.from_name, frame.to_session ?? null, frame.expires_at, frame.ts]);
|
|
170
|
+
case "publish":
|
|
171
|
+
return JSON.stringify([frame.kind, frame.msg_id, frame.work_key, frame.task_id, frame.from_session, frame.from_name, frame.title, frame.excerpt, frame.ts]);
|
|
133
172
|
}
|
|
134
173
|
}
|
|
135
174
|
|
package/src/exocom/gate.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/** Identity metadata does not claim work or settle asks. Persona denials still apply first. */
|
|
2
2
|
const ALLOWED = new Set(["exocom_name", "exocom_answer", "exocom_decline", "exocom_status", "read", "grep", "find", "ls"]);
|
|
3
3
|
|
|
4
|
-
export function constrainedTurnAllows(toolName: string): boolean {
|
|
5
|
-
|
|
4
|
+
export function constrainedTurnAllows(toolName: string, input?: unknown): boolean {
|
|
5
|
+
if (ALLOWED.has(toolName)) return true;
|
|
6
|
+
if (!input || typeof input !== "object") return false;
|
|
7
|
+
const action = (input as { action?: unknown }).action;
|
|
8
|
+
return (toolName === "exocom_task" && (action === "list" || action === "get"))
|
|
9
|
+
|| (toolName === "exocom_publish" && action === "read")
|
|
10
|
+
|| (toolName === "exocom_profile" && action === "get");
|
|
6
11
|
}
|
package/src/exocom/install.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
sanitizePeerField,
|
|
28
28
|
sendPersonaFollowUp,
|
|
29
29
|
shouldReportHeartbeatFailure,
|
|
30
|
+
toastCause,
|
|
30
31
|
type ExocomQueuedToast,
|
|
31
32
|
} from "../extension/shared.ts";
|
|
32
33
|
import { SeenMessages, SenderBudget } from "./guards.ts";
|
|
@@ -54,6 +55,9 @@ import type { ExocomSemanticFrame } from "./envelope.ts";
|
|
|
54
55
|
import { prune as pruneExocom, type RegistryEntry } from "./registry.ts";
|
|
55
56
|
import { registerExocomTools } from "../tools/exocom.ts";
|
|
56
57
|
import { registerExocomWorkTools } from "../tools/exocom-work.ts";
|
|
58
|
+
import { registerExocomCollaborationTools } from "../tools/exocom-collaboration.ts";
|
|
59
|
+
import { registerExocomProfileTool } from "../tools/exocom-profile.ts";
|
|
60
|
+
import { collaborationWriteIntent, deliverCollaborationOffer } from "./collaboration-delivery.ts";
|
|
57
61
|
import type { SessionIdentity } from "../extension/identity.ts";
|
|
58
62
|
import { WriteWarnings } from "./write-guard.ts";
|
|
59
63
|
|
|
@@ -237,28 +241,23 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
|
|
|
237
241
|
}
|
|
238
242
|
|
|
239
243
|
function telemetrySessionId(ctx: ExtensionContext): string {
|
|
240
|
-
return
|
|
244
|
+
return ctx.sessionManager.getSessionId();
|
|
241
245
|
}
|
|
242
246
|
|
|
243
247
|
function currentTelemetryInstance(ctx: ExtensionContext): InstanceDescriptor {
|
|
244
|
-
const runtimeCtx = ctx as ExtensionContext & {
|
|
245
|
-
sessionManager?: { getSessionId?: () => string };
|
|
246
|
-
isIdle?: () => boolean;
|
|
247
|
-
getContextUsage?: () => { percent?: number } | undefined;
|
|
248
|
-
};
|
|
249
248
|
const personaName = host.controller.activePersona?.name ?? "";
|
|
250
249
|
const displayName = exocomSelfStatusLabel(host.identity.chosen, host.identity.name, personaName);
|
|
251
250
|
return {
|
|
252
251
|
displayName,
|
|
253
252
|
persona: host.controller.activePersona?.name ?? "",
|
|
254
253
|
model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "",
|
|
255
|
-
status:
|
|
254
|
+
status: ctx.isIdle() ? "idle" : "active",
|
|
256
255
|
pid: process.pid,
|
|
257
256
|
// Clamped HERE, not merely in the producer's `sanitizeInstance`: that one runs only for
|
|
258
257
|
// `instance.started`, so an out-of-range reading would ride `instance.updated` and
|
|
259
258
|
// `instance.heartbeat` out to consumers, where the contract rejects the WHOLE event over
|
|
260
259
|
// the one bad field.
|
|
261
|
-
contextPercent: Math.max(0, Math.min(100, Math.round(
|
|
260
|
+
contextPercent: Math.max(0, Math.min(100, Math.round(ctx.getContextUsage()?.percent ?? 0))),
|
|
262
261
|
exocomEnabled: exocomPlane !== undefined,
|
|
263
262
|
color: exocomColorFor(displayName),
|
|
264
263
|
};
|
|
@@ -412,7 +411,7 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
|
|
|
412
411
|
exocomSeen = new SeenMessages({ ttlMs: EXOCOM.SEEN_TTL_MS });
|
|
413
412
|
exocomLedgerFile = ledgerPath(agentDir, hash);
|
|
414
413
|
exocomWaitNotifier = new IdleCoalescingNotifier<string>({
|
|
415
|
-
isIdle: () => canDeliverPersonaNotification(host.orchestrating, host.processingDeferredOrchestration, host.lastCtx?.isIdle
|
|
414
|
+
isIdle: () => canDeliverPersonaNotification(host.orchestrating, host.processingDeferredOrchestration, host.lastCtx?.isIdle() === true),
|
|
416
415
|
deliver: (message) => sendPersonaFollowUp(pi, message, "pi-persona"),
|
|
417
416
|
render: (items) => items.join("\n\n"),
|
|
418
417
|
setTimer: (fn, ms) => { const h = setTimeout(fn, ms); h.unref?.(); return h; },
|
|
@@ -423,7 +422,7 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
|
|
|
423
422
|
// pi.sendMessage (a distinct, labellable custom message) rather than pi.sendUserMessage
|
|
424
423
|
// (mirrors the bridge's own `sendFollowUp` for inbound cross-process text).
|
|
425
424
|
exocomNotifier = new IdleCoalescingNotifier<string>({
|
|
426
|
-
isIdle: () => canDeliverPersonaNotification(host.orchestrating, host.processingDeferredOrchestration, host.lastCtx?.isIdle
|
|
425
|
+
isIdle: () => canDeliverPersonaNotification(host.orchestrating, host.processingDeferredOrchestration, host.lastCtx?.isIdle() === true),
|
|
427
426
|
deliver: (message) => sendPersonaFollowUp(pi, message, "exocom_received"),
|
|
428
427
|
// A burst of independent peers must not turn one idle wake into an unbounded model
|
|
429
428
|
// payload. Each item is already attributed and peer-fenced by buildInboundDelivery;
|
|
@@ -536,7 +535,7 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
|
|
|
536
535
|
if (!plane) throw new Error("exocom is not active for this persona");
|
|
537
536
|
if (!exocomLedgerFile) throw new Error("exocom ledger is not available");
|
|
538
537
|
let target: DisplayPeer | undefined;
|
|
539
|
-
if (frame.kind === "ask") {
|
|
538
|
+
if (frame.kind === "ask" || (frame.kind === "offer" && frame.to_session !== undefined)) {
|
|
540
539
|
target = plane.listPeers().find((peer) => peer.session_id === frame.to_session);
|
|
541
540
|
if (!target) throw new Error(`exocom: unknown peer session "${frame.to_session}"`);
|
|
542
541
|
} else if (frame.kind === "answer") {
|
|
@@ -550,7 +549,14 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
|
|
|
550
549
|
}
|
|
551
550
|
}
|
|
552
551
|
const prune = currentLedgerPruneOptions();
|
|
553
|
-
|
|
552
|
+
if (collaborationWriteIntent(frame, currentLedgerState())
|
|
553
|
+
&& (exocomScope?.joined === true || (host.controller.capabilities && !canCallTool(host.controller.capabilities, "exocom_claim")))) {
|
|
554
|
+
throw new Error("exocom: this session cannot claim write paths in the selected scope");
|
|
555
|
+
}
|
|
556
|
+
const applied = commitLedgerEvent(exocomLedgerFile, frame, {
|
|
557
|
+
...(prune ? { prune } : {}),
|
|
558
|
+
canClaim: exocomScope?.joined !== true && (!host.controller.capabilities || canCallTool(host.controller.capabilities, "exocom_claim")),
|
|
559
|
+
});
|
|
554
560
|
if (!applied.ok) throw new Error(`exocom: ${applied.error}`);
|
|
555
561
|
if (applied.duplicate === true) return { msg_id: frame.msg_id };
|
|
556
562
|
// Claims/progress/releases are authoritative in the shared ledger and need no postcard.
|
|
@@ -562,7 +568,7 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
|
|
|
562
568
|
} catch { /* the durable result is still recoverable from the ledger */ }
|
|
563
569
|
return { msg_id: frame.msg_id, peerWakeDeferred: true };
|
|
564
570
|
}
|
|
565
|
-
if (target && (frame.kind === "ask" || frame.kind === "answer")) {
|
|
571
|
+
if (target && (frame.kind === "ask" || frame.kind === "answer" || frame.kind === "offer")) {
|
|
566
572
|
try {
|
|
567
573
|
await plane.sendSemantic(target.target, frame);
|
|
568
574
|
} catch {
|
|
@@ -657,7 +663,7 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
|
|
|
657
663
|
exocomNotifier?.notify(decision.deliver);
|
|
658
664
|
// Never inject peer text into a live model turn. A compact human-only toast makes
|
|
659
665
|
// receipt visible while a long tool loop keeps the follow-up idle-gated.
|
|
660
|
-
if (host.lastCtx?.isIdle
|
|
666
|
+
if (host.lastCtx?.isIdle() !== true) {
|
|
661
667
|
exocomToastNotifier?.notify({ label, inReplyTo: msg.in_reply_to });
|
|
662
668
|
}
|
|
663
669
|
}
|
|
@@ -692,7 +698,15 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
|
|
|
692
698
|
return { accepted: false, reason: error instanceof Error ? error.message : "ledger unavailable" };
|
|
693
699
|
}
|
|
694
700
|
const label = exocomPlane?.humanDisplayLabelFor(fromEntry) ?? fromEntry.session_id;
|
|
695
|
-
if (
|
|
701
|
+
if (fromEntry.workspace_id !== undefined && fromEntry.workspace_id !== scope.scopeWorkspaceId && collaborationWriteIntent(frame, state)) {
|
|
702
|
+
exocomSeen?.forget(frame.from_session, frame.msg_id);
|
|
703
|
+
return { accepted: false, reason: "external-workspace peers cannot take write tasks in the selected scope" };
|
|
704
|
+
}
|
|
705
|
+
if (frame.kind === "offer") {
|
|
706
|
+
const delivery = deliverCollaborationOffer(frame, state, sessionId, label, Date.now());
|
|
707
|
+
if (!delivery.accepted) { exocomSeen?.forget(frame.from_session, frame.msg_id); return delivery; }
|
|
708
|
+
if (delivery.notice) exocomNotifier?.notify(delivery.notice);
|
|
709
|
+
} else if (frame.kind === "ask") {
|
|
696
710
|
if (frame.to_session !== sessionId) {
|
|
697
711
|
exocomSeen?.forget(frame.from_session, frame.msg_id);
|
|
698
712
|
return { accepted: false, reason: "ask addressed to another session" };
|
|
@@ -740,7 +754,7 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
|
|
|
740
754
|
if (shouldReportHeartbeatFailure(exocomHeartbeatFailures)) {
|
|
741
755
|
try {
|
|
742
756
|
host.lastCtx?.ui.notify(
|
|
743
|
-
`exocom: heartbeat failed ${exocomHeartbeatFailures}× in a row (${message}) — peers drop this instance from the pool until it recovers.`,
|
|
757
|
+
`exocom: heartbeat failed ${exocomHeartbeatFailures}× in a row (${toastCause(message)}) — peers drop this instance from the pool until it recovers.`,
|
|
744
758
|
"warning",
|
|
745
759
|
);
|
|
746
760
|
} catch {
|
|
@@ -775,6 +789,19 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
|
|
|
775
789
|
ledger: () => currentLedgerState(),
|
|
776
790
|
labelFor: peerLabelFor,
|
|
777
791
|
});
|
|
792
|
+
registerExocomCollaborationTools(pi, {
|
|
793
|
+
getPlane: () => exocomPlane,
|
|
794
|
+
sessionId: () => sessionId, name: () => host.identity.name, now: () => Date.now(),
|
|
795
|
+
canClaim: () => exocomScope?.joined !== true,
|
|
796
|
+
canCallTool: (name) => !host.controller.capabilities || canCallTool(host.controller.capabilities, name),
|
|
797
|
+
resolveTarget: (target) => {
|
|
798
|
+
if (!exocomPlane) throw new Error("exocom is not active for this persona");
|
|
799
|
+
return exocomPlane.resolvePeer(target).session_id;
|
|
800
|
+
},
|
|
801
|
+
dispatch: dispatchSemantic, ledger: currentLedgerState, labelFor: peerLabelFor,
|
|
802
|
+
targetForSession: (id) => exocomPlane?.listPeers().find((peer) => peer.session_id === id)?.target,
|
|
803
|
+
});
|
|
804
|
+
registerExocomProfileTool(pi, () => exocomPlane);
|
|
778
805
|
} catch (err) {
|
|
779
806
|
// `plane.start()` may have bound its server before a later registry write failed.
|
|
780
807
|
// Teardown while the reference is still owned; this also cancels notifier/timers.
|
|
@@ -783,7 +810,7 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
|
|
|
783
810
|
let surfacedInUi = false;
|
|
784
811
|
if (ctx.hasUI) {
|
|
785
812
|
try {
|
|
786
|
-
ctx.ui.notify(`exocom failed to start: ${message}`, "error");
|
|
813
|
+
ctx.ui.notify(`exocom failed to start: ${toastCause(message)}`, "error");
|
|
787
814
|
surfacedInUi = true;
|
|
788
815
|
} catch {
|
|
789
816
|
/* the session may already be tearing down; stderr remains available */
|
|
@@ -891,6 +918,7 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
|
|
|
891
918
|
const workKeys = new Set([
|
|
892
919
|
...state.claims.filter((claim) => claim.from_session === exocomSessionId).map((claim) => claim.work_key),
|
|
893
920
|
...state.asks.filter((ask) => ask.from_session === exocomSessionId).map((ask) => ask.work_key),
|
|
921
|
+
...(state.collaboration?.tasks.filter((task) => !task.finished && (task.taken?.from_session ?? task.created.from_session) === exocomSessionId).map((task) => task.created.work_key) ?? []),
|
|
894
922
|
]);
|
|
895
923
|
for (const work_key of workKeys) {
|
|
896
924
|
const prune = currentLedgerPruneOptions();
|
package/src/exocom/ledger.ts
CHANGED
|
@@ -8,7 +8,8 @@ import { dirname } from "node:path";
|
|
|
8
8
|
|
|
9
9
|
import { attributePeer } from "../core/fence.ts";
|
|
10
10
|
import { findWriteSetOverlaps, writeSetPathError } from "../core/ownership.ts";
|
|
11
|
-
import { isSemanticFrame, truncateForInject, type ExocomAsk, type ExocomClaim, type ExocomSemanticFrame } from "./envelope.ts";
|
|
11
|
+
import { frameSigningPayload, isSemanticFrame, truncateForInject, type ExocomAsk, type ExocomClaim, type ExocomSemanticFrame } from "./envelope.ts";
|
|
12
|
+
import { applyCollaborationEvent, collaborationStatus, emptyCollaboration, parseCollaboration, pruneCollaboration, releaseCollaboration, retainedCollaborationEvent, type CollaborationState } from "./collaboration.ts";
|
|
12
13
|
|
|
13
14
|
export type LedgerEvent = ExocomSemanticFrame;
|
|
14
15
|
|
|
@@ -33,6 +34,8 @@ export interface LedgerState {
|
|
|
33
34
|
/** Accepted ask ids, including completed asks, so ids cannot be recycled in this retained ledger window. */
|
|
34
35
|
askIds: string[];
|
|
35
36
|
seen: string[];
|
|
37
|
+
/** Optional only for older in-memory callers; current snapshots retain the bounded peer-work state. */
|
|
38
|
+
collaboration?: CollaborationState;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
export type ApplyResult =
|
|
@@ -56,7 +59,7 @@ export const LEDGER_LIMITS = {
|
|
|
56
59
|
} as const;
|
|
57
60
|
|
|
58
61
|
export function emptyLedger(): LedgerState {
|
|
59
|
-
return { claims: [], asks: [], answers: [], askIds: [], seen: [] };
|
|
62
|
+
return { claims: [], asks: [], answers: [], askIds: [], seen: [], collaboration: emptyCollaboration() };
|
|
60
63
|
}
|
|
61
64
|
|
|
62
65
|
function remember(state: LedgerState, msgId: string): LedgerState {
|
|
@@ -99,8 +102,31 @@ export function applyLedgerEvent(state: LedgerState, event: LedgerEvent): ApplyR
|
|
|
99
102
|
}
|
|
100
103
|
return { ok: false, error: "malformed ledger event" };
|
|
101
104
|
}
|
|
105
|
+
const retained = retainedCollaborationEvent(state.collaboration ?? emptyCollaboration(), event.msg_id);
|
|
106
|
+
if (retained) return frameSigningPayload(retained) === frameSigningPayload(event)
|
|
107
|
+
? { ok: true, state, duplicate: true } : { ok: false, error: "msg_id conflicts with a retained collaboration fact" };
|
|
102
108
|
if (state.seen.includes(event.msg_id)) return { ok: true, state, duplicate: true };
|
|
103
109
|
switch (event.kind) {
|
|
110
|
+
case "task_create":
|
|
111
|
+
case "task_take":
|
|
112
|
+
case "task_finish":
|
|
113
|
+
case "offer":
|
|
114
|
+
case "publish": {
|
|
115
|
+
const collaboration = state.collaboration ?? emptyCollaboration();
|
|
116
|
+
const applied = applyCollaborationEvent(collaboration, event);
|
|
117
|
+
if (!applied.ok) return applied;
|
|
118
|
+
let claims = state.claims;
|
|
119
|
+
const task = collaboration.tasks.find((candidate) => candidate.created.work_key === event.work_key);
|
|
120
|
+
if (event.kind === "task_take" && task?.created.write_set.length) {
|
|
121
|
+
if (claims.some((claim) => claim.work_key === event.work_key && claim.from_session === event.from_session)) return { ok: false, error: "task pickup conflicts with an existing owned claim on this work_key" };
|
|
122
|
+
const claimed = applyLedgerEvent(state, { kind: "claim", work_key: event.work_key, from_session: event.from_session, from_name: event.from_name,
|
|
123
|
+
write_set: [...task.created.write_set], slice: task.created.description.slice(0, 256), msg_id: event.msg_id, ts: event.ts });
|
|
124
|
+
if (!claimed.ok) return claimed;
|
|
125
|
+
claims = claimed.state.claims;
|
|
126
|
+
}
|
|
127
|
+
if (event.kind === "task_finish" && task?.taken) claims = claims.filter((claim) => claim.msg_id !== task.taken!.msg_id);
|
|
128
|
+
return { ok: true, state: remember({ ...state, claims, collaboration: applied.state }, event.msg_id) };
|
|
129
|
+
}
|
|
104
130
|
case "claim": {
|
|
105
131
|
if (state.claims.length >= LEDGER_LIMITS.maxClaims) return { ok: false, error: "ledger claim limit reached" };
|
|
106
132
|
for (const path of event.write_set) {
|
|
@@ -138,12 +164,18 @@ export function applyLedgerEvent(state: LedgerState, event: LedgerEvent): ApplyR
|
|
|
138
164
|
case "release": {
|
|
139
165
|
const owned = state.claims.filter((claim) => claim.work_key === event.work_key && claim.from_session === event.from_session);
|
|
140
166
|
const outbound = state.asks.filter((ask) => ask.work_key === event.work_key && ask.from_session === event.from_session);
|
|
141
|
-
|
|
167
|
+
const collaboration = state.collaboration ?? emptyCollaboration();
|
|
168
|
+
const ownsTask = collaboration.tasks.some((task) => task.created.work_key === event.work_key && !task.finished && !task.stale
|
|
169
|
+
&& (task.taken?.from_session ?? task.created.from_session) === event.from_session);
|
|
170
|
+
if (owned.length === 0 && outbound.length === 0 && !ownsTask) return { ok: false, error: "release: no owned claim or outbound ask on that work_key" };
|
|
142
171
|
const claims = state.claims.filter((claim) => !(claim.work_key === event.work_key && claim.from_session === event.from_session));
|
|
143
172
|
const asks = state.asks.filter((ask) => !(ask.work_key === event.work_key && ask.from_session === event.from_session));
|
|
144
|
-
return { ok: true, state: remember({ ...state, claims, asks }, event.msg_id) };
|
|
173
|
+
return { ok: true, state: remember({ ...state, claims, asks, collaboration: releaseCollaboration(collaboration, event.from_session, event.work_key, Date.parse(event.ts)) }, event.msg_id) };
|
|
174
|
+
}
|
|
175
|
+
case "progress": {
|
|
176
|
+
const applied = applyCollaborationEvent(state.collaboration ?? emptyCollaboration(), event);
|
|
177
|
+
return applied.ok ? { ok: true, state: remember({ ...state, collaboration: applied.state }, event.msg_id) } : applied;
|
|
145
178
|
}
|
|
146
|
-
case "progress": return { ok: true, state: remember(state, event.msg_id) };
|
|
147
179
|
}
|
|
148
180
|
}
|
|
149
181
|
|
|
@@ -154,6 +186,7 @@ interface LedgerSnapshot {
|
|
|
154
186
|
answers: LedgerAnswer[];
|
|
155
187
|
ask_ids: string[];
|
|
156
188
|
seen: string[];
|
|
189
|
+
collaboration?: CollaborationState;
|
|
157
190
|
}
|
|
158
191
|
|
|
159
192
|
function token(value: unknown): value is string {
|
|
@@ -216,7 +249,15 @@ function parseSnapshot(raw: unknown): LedgerState | undefined {
|
|
|
216
249
|
}
|
|
217
250
|
const requiredAskIds = new Set(state.askIds);
|
|
218
251
|
if (![...requiredAskIds].every((askId) => snapshot.ask_ids!.includes(askId))) return undefined;
|
|
219
|
-
|
|
252
|
+
const collaboration = snapshot.collaboration === undefined ? emptyCollaboration() : parseCollaboration(snapshot.collaboration);
|
|
253
|
+
if (!collaboration) return undefined;
|
|
254
|
+
for (const task of collaboration.tasks) {
|
|
255
|
+
if (collaborationStatus(task) !== "taken" || !task.created.write_set.length) continue;
|
|
256
|
+
const claim = state.claims.find((candidate) => candidate.msg_id === task.taken?.msg_id);
|
|
257
|
+
if (!claim || claim.from_session !== task.taken?.from_session || claim.work_key !== task.created.work_key
|
|
258
|
+
|| JSON.stringify(claim.write_set) !== JSON.stringify(task.created.write_set)) return undefined;
|
|
259
|
+
}
|
|
260
|
+
return { ...state, collaboration, askIds: [...snapshot.ask_ids], seen: [...snapshot.seen] };
|
|
220
261
|
}
|
|
221
262
|
|
|
222
263
|
function snapshotOf(state: LedgerState): LedgerSnapshot {
|
|
@@ -227,6 +268,7 @@ function snapshotOf(state: LedgerState): LedgerSnapshot {
|
|
|
227
268
|
answers: state.answers,
|
|
228
269
|
ask_ids: state.askIds,
|
|
229
270
|
seen: state.seen,
|
|
271
|
+
collaboration: state.collaboration ?? emptyCollaboration(),
|
|
230
272
|
};
|
|
231
273
|
}
|
|
232
274
|
|
|
@@ -246,6 +288,8 @@ export function parseLedger(text: string): LedgerState {
|
|
|
246
288
|
state = restored;
|
|
247
289
|
continue;
|
|
248
290
|
}
|
|
291
|
+
if (raw && typeof raw === "object" && typeof (raw as { kind?: unknown }).kind === "string"
|
|
292
|
+
&& (raw as { kind: string }).kind.startsWith("ledger_snapshot_")) throw new Error("unsupported ledger snapshot version");
|
|
249
293
|
if (!isSemanticFrame(raw)) continue;
|
|
250
294
|
const result = applyLedgerEvent(state, raw);
|
|
251
295
|
if (result.ok) state = result.state;
|
|
@@ -276,7 +320,7 @@ export function pruneLedger(state: LedgerState, options: LedgerPruneOptions): Le
|
|
|
276
320
|
const asks = state.asks.filter((ask) => options.liveSessions.has(ask.from_session) && options.liveSessions.has(ask.to_session));
|
|
277
321
|
const answers = state.answers.filter((answer) => isFresh(answer.ts, now, ttlMs)).slice(-LEDGER_LIMITS.maxResults);
|
|
278
322
|
const askIds = [...new Set([...asks.map((ask) => ask.ask_id), ...answers.map((answer) => answer.ask_id)])];
|
|
279
|
-
return { ...state, claims, asks, answers, askIds };
|
|
323
|
+
return { ...state, claims, asks, answers, askIds, collaboration: pruneCollaboration(state.collaboration ?? emptyCollaboration(), now, ttlMs, (session) => options.liveSessions.has(session)) };
|
|
280
324
|
}
|
|
281
325
|
|
|
282
326
|
/** The pending asks a prune dropped (a party left the pool) — the ids a waiter must be woken for. */
|
|
@@ -565,6 +609,8 @@ export function appendLedgerEvent(path: string, event: LedgerEvent): void {
|
|
|
565
609
|
export interface LedgerCommitOptions {
|
|
566
610
|
/** Applied under the same transaction lock before validating the new event. */
|
|
567
611
|
prune?: LedgerPruneOptions;
|
|
612
|
+
/** Authoritative write admission, evaluated against the locked task definition (not a preflight read). */
|
|
613
|
+
canClaim?: boolean;
|
|
568
614
|
/** Test/operations override; defaults to half the maximum journal size. */
|
|
569
615
|
compactAtBytes?: number;
|
|
570
616
|
/** Fault-injection seam for short-write and Windows replacement regression tests. */
|
|
@@ -578,7 +624,8 @@ export interface LedgerFileIO {
|
|
|
578
624
|
|
|
579
625
|
function pruningChanged(before: LedgerState, after: LedgerState): boolean {
|
|
580
626
|
return before.claims.length !== after.claims.length || before.asks.length !== after.asks.length
|
|
581
|
-
|| before.answers.length !== after.answers.length || before.askIds.length !== after.askIds.length
|
|
627
|
+
|| before.answers.length !== after.answers.length || before.askIds.length !== after.askIds.length
|
|
628
|
+
|| JSON.stringify(before.collaboration) !== JSON.stringify(after.collaboration);
|
|
582
629
|
}
|
|
583
630
|
|
|
584
631
|
export function commitLedgerEvent(path: string, event: LedgerEvent, options: LedgerCommitOptions = {}): ApplyResult {
|
|
@@ -588,6 +635,9 @@ export function commitLedgerEvent(path: string, event: LedgerEvent, options: Led
|
|
|
588
635
|
const sizeBeforeRecovery = recoverIncompleteTrailingRecordLocked(path);
|
|
589
636
|
const loaded = loadLedger(path);
|
|
590
637
|
const pruned = options.prune ? pruneLedger(loaded, options.prune) : loaded;
|
|
638
|
+
const writes = event.kind === "claim" || event.kind === "task_create" ? event.write_set.length > 0
|
|
639
|
+
: event.kind === "task_take" && (pruned.collaboration?.tasks.find((task) => task.created.work_key === event.work_key)?.created.write_set.length ?? 0) > 0;
|
|
640
|
+
if (options.canClaim === false && writes) return { ok: false, error: "this session cannot claim write paths in the selected scope" };
|
|
591
641
|
const result = applyLedgerEvent(pruned, event);
|
|
592
642
|
const compactAt = Number.isFinite(options.compactAtBytes) && options.compactAtBytes !== undefined
|
|
593
643
|
? Math.max(1, Math.floor(options.compactAtBytes)) : Math.floor(LEDGER_LIMITS.maxBytes / 2);
|