zob-harness 0.7.1 → 0.8.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/.pi/capabilities/zob-public-runtime-capabilities.json +1 -1
- package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/registry.ts +363 -22
- package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/types.ts +40 -0
- package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/zpeer.ts +6 -2
- package/.pi/extensions/zob-harness/src/domains/coms/zagents.ts +304 -0
- package/.pi/extensions/zob-harness/src/runtime/commands.ts +266 -8
- package/.pi/extensions/zob-harness/src/runtime/events.ts +134 -13
- package/.pi/extensions/zob-harness/src/runtime/state.ts +22 -0
- package/.pi/extensions/zob-harness/src/runtime/widget.ts +35 -2
- package/.pi/skills/zob-factory/SKILL.md +4 -2
- package/.pi/skills/zob-zagent-creator/SKILL.md +62 -9
- package/.pi/zagents/harness-architect.json +67 -0
- package/.pi/zagents/harness-chief.json +73 -0
- package/.pi/zagents/harness-coms-steward.json +64 -0
- package/.pi/zagents/harness-factory-engineer.json +61 -0
- package/.pi/zagents/harness-implementer.json +69 -0
- package/.pi/zagents/harness-oracle.json +70 -0
- package/.pi/zagents/prompts/harness-architect.md +52 -0
- package/.pi/zagents/prompts/harness-chief.md +66 -0
- package/.pi/zagents/prompts/harness-coms-steward.md +50 -0
- package/.pi/zagents/prompts/harness-factory-engineer.md +49 -0
- package/.pi/zagents/prompts/harness-implementer.md +56 -0
- package/.pi/zagents/prompts/harness-oracle.md +54 -0
- package/.pi/zteams/agent-factory-pacman-multiplayer.tmux.sh +27 -0
- package/.pi/zteams/zob-harness-devs.json +97 -0
- package/.pi/zteams/zob-harness-devs.modes.json +111 -0
- package/.pi/zteams/zob-harness-devs.tmux.sh +239 -0
- package/package.json +3 -1
- package/scripts/zagent-static-smoke.mjs +238 -1
- package/scripts/zpeer-local-e2e-smoke.mjs +9 -7
- package/scripts/zpeer-static-smoke.mjs +5 -0
- package/scripts/zteam-lifecycle-smoke.mjs +154 -0
|
@@ -1626,7 +1626,7 @@
|
|
|
1626
1626
|
"AGENTS.md",
|
|
1627
1627
|
".pi/skills/zob-zagent-creator/SKILL.md"
|
|
1628
1628
|
],
|
|
1629
|
-
"noShipNotes": "Project-local ZTeam bundle UX for .pi/zteams/ only. ZTeam members are full-session Pi/ZPeer identities tied to live coordination, not delegate subagents; launch-plan is plan/metadata only and must not spawn processes. Persisted command metadata must remain hash/body-free."
|
|
1629
|
+
"noShipNotes": "Project-local ZTeam bundle UX for .pi/zteams/ only. ZTeam members are full-session Pi/ZPeer identities tied to live coordination, not delegate subagents; launch-plan is plan/metadata only and must not spawn processes; reset sends Pi /new to existing scoped tmux agent windows without close/start. Persisted command metadata must remain hash/body-free."
|
|
1630
1630
|
},
|
|
1631
1631
|
{
|
|
1632
1632
|
"name": "rules_status",
|
|
@@ -5,13 +5,16 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
import { loadTeamDefinition, validateTeamDefinition } from "../../topology/teams.js";
|
|
6
6
|
import { isRecord } from "../../../core/utils/records.js";
|
|
7
7
|
import { safeFileStem } from "../../../core/utils/paths.js";
|
|
8
|
+
import { sha256 } from "../../../core/utils/hashing.js";
|
|
8
9
|
import { buildCurrentZobLivePeerCard, buildZobComsProjectId } from "./identity.js";
|
|
10
|
+
import { pingZobLocalEndpoint } from "./local-transport.js";
|
|
9
11
|
import { readZobComsV2Policy, zobComsRegistryEnabled } from "./policy.js";
|
|
10
|
-
import type { ZobLivePeerCard, ZobLivePeerStatus, ZobLiveRegistrySnapshot } from "./types.js";
|
|
12
|
+
import type { ZobLivePeerCard, ZobLivePeerStatus, ZobLiveRegistrySnapshot, ZobLiveTeamAgentLease } from "./types.js";
|
|
11
13
|
|
|
12
14
|
const FORBIDDEN_PERSISTED_KEYS = new Set(["body", "task", "prompt", "output", "content", "message", "rationale", "text", "diff", "patch"]);
|
|
13
15
|
const DEFAULT_OFFLINE_PEER_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
14
16
|
const MIN_OFFLINE_PEER_RETENTION_MS = 5 * 60 * 1000;
|
|
17
|
+
const MIN_LEASE_TTL_MS = 30_000;
|
|
15
18
|
|
|
16
19
|
function registryRoot(): { path: string; kind: "user_runtime" | "env_override" } {
|
|
17
20
|
const override = process.env.ZOB_COMS_REGISTRY_ROOT;
|
|
@@ -31,6 +34,12 @@ function projectAgentsDir(repoRoot: string): { dir: string; projectId: string; k
|
|
|
31
34
|
return { dir: join(root.path, "projects", projectId, "agents"), projectId, kind: root.kind };
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
function projectLeasesDir(repoRoot: string): { dir: string; projectId: string; kind: "user_runtime" | "env_override" } {
|
|
38
|
+
const root = registryRoot();
|
|
39
|
+
const projectId = buildZobComsProjectId(repoRoot);
|
|
40
|
+
return { dir: join(root.path, "projects", projectId, "leases"), projectId, kind: root.kind };
|
|
41
|
+
}
|
|
42
|
+
|
|
34
43
|
function peerPath(repoRoot: string, peer: Pick<ZobLivePeerCard, "roleId" | "sessionHash">): string {
|
|
35
44
|
const { dir } = projectAgentsDir(repoRoot);
|
|
36
45
|
return join(dir, `${safeFileStem(`${peer.roleId}-${peer.sessionHash.slice(0, 12)}`)}.json`);
|
|
@@ -40,6 +49,22 @@ function peerPathForProjectId(projectId: string, peer: Pick<ZobLivePeerCard, "ro
|
|
|
40
49
|
return join(registryRoot().path, "projects", safeFileStem(projectId), "agents", `${safeFileStem(`${peer.roleId}-${peer.sessionHash.slice(0, 12)}`)}.json`);
|
|
41
50
|
}
|
|
42
51
|
|
|
52
|
+
function stableLeaseStem(input: Pick<ZobLivePeerCard, "team" | "roleId"> | Pick<ZobLiveTeamAgentLease, "teamId" | "agentId">): string {
|
|
53
|
+
const teamId = "team" in input ? input.team : input.teamId;
|
|
54
|
+
const agentId = "roleId" in input ? input.roleId : input.agentId;
|
|
55
|
+
return safeFileStem(`${teamId}-${agentId}`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function leasePath(repoRoot: string, peer: Pick<ZobLivePeerCard, "team" | "roleId">): string {
|
|
59
|
+
const { dir } = projectLeasesDir(repoRoot);
|
|
60
|
+
return join(dir, `${stableLeaseStem(peer)}.json`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function leasePathForTeamAgent(repoRoot: string, teamId: string, agentId: string): string {
|
|
64
|
+
const { dir } = projectLeasesDir(repoRoot);
|
|
65
|
+
return join(dir, `${safeFileStem(`${teamId}-${agentId}`)}.json`);
|
|
66
|
+
}
|
|
67
|
+
|
|
43
68
|
function readPeerCardsFromAgentsDir(dir: string, nowMs: number, teamName?: string): ZobLivePeerCard[] {
|
|
44
69
|
if (!existsSync(dir)) return [];
|
|
45
70
|
return readdirSync(dir)
|
|
@@ -56,6 +81,22 @@ function readPeerCardsFromAgentsDir(dir: string, nowMs: number, teamName?: strin
|
|
|
56
81
|
.map((peer) => ({ ...peer, status: derivePeerStatus(peer, nowMs) }));
|
|
57
82
|
}
|
|
58
83
|
|
|
84
|
+
function readLeasesFromDir(dir: string, nowMs: number, teamName?: string): ZobLivePeerCard[] {
|
|
85
|
+
if (!existsSync(dir)) return [];
|
|
86
|
+
return readdirSync(dir)
|
|
87
|
+
.filter((entry) => entry.endsWith(".json"))
|
|
88
|
+
.map((entry) => {
|
|
89
|
+
try {
|
|
90
|
+
return parseTeamAgentLease(JSON.parse(readFileSync(join(dir, entry), "utf8")) as unknown);
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
.filter((lease): lease is ZobLiveTeamAgentLease => Boolean(lease))
|
|
96
|
+
.filter((lease) => !teamName || lease.teamId === teamName)
|
|
97
|
+
.map((lease) => leaseToPeerCard(lease, nowMs));
|
|
98
|
+
}
|
|
99
|
+
|
|
59
100
|
function boundedOfflinePeerRetentionMs(value: number | undefined): number {
|
|
60
101
|
const env = Number.parseInt(process.env.ZOB_COMS_OFFLINE_PEER_RETENTION_MS ?? "", 10);
|
|
61
102
|
const raw = typeof value === "number" && Number.isFinite(value) ? value : Number.isFinite(env) ? env : DEFAULT_OFFLINE_PEER_RETENTION_MS;
|
|
@@ -69,6 +110,12 @@ function offlinePeerExpired(peer: ZobLivePeerCard, nowMs: number, retentionMs: n
|
|
|
69
110
|
return nowMs - heartbeatMs >= Math.max(peer.offlineAfterMs, retentionMs);
|
|
70
111
|
}
|
|
71
112
|
|
|
113
|
+
function leaseExpired(lease: ZobLiveTeamAgentLease, nowMs: number, retentionMs = 0): boolean {
|
|
114
|
+
const expiresMs = Date.parse(lease.expiresAt);
|
|
115
|
+
if (!Number.isFinite(expiresMs)) return true;
|
|
116
|
+
return nowMs >= expiresMs + Math.max(0, retentionMs);
|
|
117
|
+
}
|
|
118
|
+
|
|
72
119
|
function allProjectAgentsDirs(): string[] {
|
|
73
120
|
const root = registryRoot();
|
|
74
121
|
const projectsDir = join(root.path, "projects");
|
|
@@ -78,6 +125,15 @@ function allProjectAgentsDirs(): string[] {
|
|
|
78
125
|
.map((entry) => join(projectsDir, entry.name, "agents"));
|
|
79
126
|
}
|
|
80
127
|
|
|
128
|
+
function allProjectLeasesDirs(): string[] {
|
|
129
|
+
const root = registryRoot();
|
|
130
|
+
const projectsDir = join(root.path, "projects");
|
|
131
|
+
if (!existsSync(projectsDir)) return [];
|
|
132
|
+
return readdirSync(projectsDir, { withFileTypes: true })
|
|
133
|
+
.filter((entry) => entry.isDirectory())
|
|
134
|
+
.map((entry) => join(projectsDir, entry.name, "leases"));
|
|
135
|
+
}
|
|
136
|
+
|
|
81
137
|
function buildSnapshot(projectId: string, kind: "user_runtime" | "env_override", peers: ZobLivePeerCard[], teamName?: string): ZobLiveRegistrySnapshot {
|
|
82
138
|
const counts: Record<ZobLivePeerStatus, number> = { online: 0, stale: 0, offline: 0 };
|
|
83
139
|
for (const peer of peers) counts[peer.status] += 1;
|
|
@@ -100,6 +156,15 @@ function parsePeerCard(value: unknown): ZobLivePeerCard | undefined {
|
|
|
100
156
|
return value as unknown as ZobLivePeerCard;
|
|
101
157
|
}
|
|
102
158
|
|
|
159
|
+
function parseTeamAgentLease(value: unknown): ZobLiveTeamAgentLease | undefined {
|
|
160
|
+
if (!isRecord(value) || value.schema !== "zob.live-team-agent-lease.v1") return undefined;
|
|
161
|
+
if (hasForbiddenPersistedKey(value) || value.bodyStored !== false || value.localOnly !== true || value.networkEnabled !== false) return undefined;
|
|
162
|
+
if (value.stableLease !== true || value.exclusiveBy !== "teamId+agentId") return undefined;
|
|
163
|
+
if (typeof value.teamId !== "string" || typeof value.agentId !== "string" || typeof value.sessionHash !== "string") return undefined;
|
|
164
|
+
if (typeof value.leaseOwnerId !== "string" || typeof value.endpoint !== "string" || typeof value.endpointHash !== "string") return undefined;
|
|
165
|
+
return value as unknown as ZobLiveTeamAgentLease;
|
|
166
|
+
}
|
|
167
|
+
|
|
103
168
|
function derivePeerStatus(peer: ZobLivePeerCard, nowMs: number): ZobLivePeerStatus {
|
|
104
169
|
if (peer.status === "offline") return "offline";
|
|
105
170
|
const heartbeatMs = Date.parse(peer.heartbeatAt);
|
|
@@ -109,32 +174,206 @@ function derivePeerStatus(peer: ZobLivePeerCard, nowMs: number): ZobLivePeerStat
|
|
|
109
174
|
return "online";
|
|
110
175
|
}
|
|
111
176
|
|
|
177
|
+
function deriveLeaseStatus(lease: ZobLiveTeamAgentLease, nowMs: number): ZobLivePeerStatus {
|
|
178
|
+
if (lease.status === "offline" || leaseExpired(lease, nowMs)) return "offline";
|
|
179
|
+
const heartbeatMs = Date.parse(lease.heartbeatAt);
|
|
180
|
+
if (!Number.isFinite(heartbeatMs)) return "stale";
|
|
181
|
+
if (nowMs - heartbeatMs >= lease.offlineAfterMs) return "offline";
|
|
182
|
+
if (nowMs - heartbeatMs >= lease.staleAfterMs) return "stale";
|
|
183
|
+
return "online";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function leaseOwnerId(peer: Pick<ZobLivePeerCard, "team" | "roleId" | "sessionHash" | "endpointHash">): string {
|
|
187
|
+
return `${peer.team}:${peer.roleId}:${peer.sessionHash}:${peer.endpointHash}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function sameLeaseOwner(lease: ZobLiveTeamAgentLease, peer: Pick<ZobLivePeerCard, "team" | "roleId" | "sessionHash" | "endpointHash">): boolean {
|
|
191
|
+
return lease.teamId === peer.team
|
|
192
|
+
&& lease.agentId === peer.roleId
|
|
193
|
+
&& lease.sessionHash === peer.sessionHash
|
|
194
|
+
&& lease.endpointHash === peer.endpointHash
|
|
195
|
+
&& lease.leaseOwnerId === leaseOwnerId(peer);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function leaseExpiresAt(peer: Pick<ZobLivePeerCard, "offlineAfterMs" | "staleAfterMs">, nowMs: number): string {
|
|
199
|
+
const ttlMs = Math.max(MIN_LEASE_TTL_MS, Math.floor(Math.max(peer.offlineAfterMs, peer.staleAfterMs * 2)));
|
|
200
|
+
return new Date(nowMs + ttlMs).toISOString();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function buildTeamAgentLease(repoRoot: string, peer: ZobLivePeerCard, input: { nowMs?: number } = {}): ZobLiveTeamAgentLease {
|
|
204
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
205
|
+
const now = new Date(nowMs).toISOString();
|
|
206
|
+
const projectId = buildZobComsProjectId(repoRoot);
|
|
207
|
+
const ownerId = leaseOwnerId(peer);
|
|
208
|
+
return {
|
|
209
|
+
schema: "zob.live-team-agent-lease.v1",
|
|
210
|
+
projectId,
|
|
211
|
+
teamId: peer.team,
|
|
212
|
+
agentId: peer.roleId,
|
|
213
|
+
roleId: peer.roleId,
|
|
214
|
+
roleType: peer.roleType,
|
|
215
|
+
leadId: peer.leadId,
|
|
216
|
+
agent: peer.agent,
|
|
217
|
+
sessionId: peer.sessionId,
|
|
218
|
+
sessionHash: peer.sessionHash,
|
|
219
|
+
leaseOwnerId: ownerId,
|
|
220
|
+
leaseOwnerHash: sha256(ownerId),
|
|
221
|
+
transport: peer.transport,
|
|
222
|
+
endpoint: peer.endpoint,
|
|
223
|
+
endpointHash: peer.endpointHash,
|
|
224
|
+
cwdHash: peer.cwdHash,
|
|
225
|
+
pid: peer.pid,
|
|
226
|
+
startedAt: peer.startedAt,
|
|
227
|
+
heartbeatAt: now,
|
|
228
|
+
leasedAt: now,
|
|
229
|
+
renewedAt: now,
|
|
230
|
+
expiresAt: leaseExpiresAt(peer, nowMs),
|
|
231
|
+
contextUsedPct: peer.contextUsedPct,
|
|
232
|
+
queueDepth: peer.queueDepth,
|
|
233
|
+
status: peer.status === "offline" ? "offline" : "online",
|
|
234
|
+
zpeerRoomId: peer.zpeerRoomId,
|
|
235
|
+
zpeerAlias: peer.zpeerAlias,
|
|
236
|
+
zpeerActiveRoomId: peer.zpeerActiveRoomId,
|
|
237
|
+
zpeerMemberships: peer.zpeerMemberships,
|
|
238
|
+
zpeerLocalOnly: peer.zpeerLocalOnly,
|
|
239
|
+
staleAfterMs: peer.staleAfterMs,
|
|
240
|
+
offlineAfterMs: peer.offlineAfterMs,
|
|
241
|
+
stableLease: true,
|
|
242
|
+
exclusiveBy: "teamId+agentId",
|
|
243
|
+
localOnly: true,
|
|
244
|
+
networkEnabled: false,
|
|
245
|
+
bodyStored: false,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function renewTeamAgentLease(repoRoot: string, existing: ZobLiveTeamAgentLease | undefined, peer: ZobLivePeerCard, input: { nowMs?: number } = {}): ZobLiveTeamAgentLease {
|
|
250
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
251
|
+
const now = new Date(nowMs).toISOString();
|
|
252
|
+
const next = buildTeamAgentLease(repoRoot, peer, { nowMs });
|
|
253
|
+
return existing && sameLeaseOwner(existing, peer)
|
|
254
|
+
? { ...next, leasedAt: existing.leasedAt, renewedAt: now }
|
|
255
|
+
: next;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function leaseToPeerCard(lease: ZobLiveTeamAgentLease, nowMs: number): ZobLivePeerCard {
|
|
259
|
+
return {
|
|
260
|
+
schema: "zob.live-peer-card.v1",
|
|
261
|
+
projectId: lease.projectId,
|
|
262
|
+
team: lease.teamId,
|
|
263
|
+
roleId: lease.roleId,
|
|
264
|
+
roleType: lease.roleType,
|
|
265
|
+
leadId: lease.leadId,
|
|
266
|
+
agent: lease.agent,
|
|
267
|
+
sessionId: lease.sessionId,
|
|
268
|
+
sessionHash: lease.sessionHash,
|
|
269
|
+
transport: lease.transport,
|
|
270
|
+
endpoint: lease.endpoint,
|
|
271
|
+
endpointHash: lease.endpointHash,
|
|
272
|
+
cwdHash: lease.cwdHash,
|
|
273
|
+
pid: lease.pid,
|
|
274
|
+
startedAt: lease.startedAt,
|
|
275
|
+
heartbeatAt: lease.heartbeatAt,
|
|
276
|
+
contextUsedPct: lease.contextUsedPct,
|
|
277
|
+
queueDepth: lease.queueDepth,
|
|
278
|
+
status: deriveLeaseStatus(lease, nowMs),
|
|
279
|
+
zpeerRoomId: lease.zpeerRoomId,
|
|
280
|
+
zpeerAlias: lease.zpeerAlias,
|
|
281
|
+
zpeerActiveRoomId: lease.zpeerActiveRoomId,
|
|
282
|
+
zpeerMemberships: lease.zpeerMemberships,
|
|
283
|
+
zpeerLocalOnly: lease.zpeerLocalOnly,
|
|
284
|
+
staleAfterMs: lease.staleAfterMs,
|
|
285
|
+
offlineAfterMs: lease.offlineAfterMs,
|
|
286
|
+
bodyStored: false,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function readLeaseAtPath(filePath: string): ZobLiveTeamAgentLease | undefined {
|
|
291
|
+
if (!existsSync(filePath)) return undefined;
|
|
292
|
+
try {
|
|
293
|
+
return parseTeamAgentLease(JSON.parse(readFileSync(filePath, "utf8")) as unknown);
|
|
294
|
+
} catch {
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function writeLeaseAtPath(filePath: string, lease: ZobLiveTeamAgentLease): ZobLiveTeamAgentLease {
|
|
300
|
+
if (hasForbiddenPersistedKey(lease)) throw new Error("Refusing to persist ZOB live team-agent lease with forbidden body-like keys");
|
|
301
|
+
if (lease.bodyStored !== false || lease.localOnly !== true || lease.networkEnabled !== false) throw new Error("ZOB live team-agent lease must be localOnly=true networkEnabled=false bodyStored=false");
|
|
302
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
303
|
+
writeFileSync(filePath, `${JSON.stringify(lease, null, 2)}\n`, "utf8");
|
|
304
|
+
return lease;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function leaseHasLocalSocketEndpoint(lease: ZobLiveTeamAgentLease): boolean {
|
|
308
|
+
return lease.transport === "local_socket"
|
|
309
|
+
&& Boolean(lease.endpoint)
|
|
310
|
+
&& !lease.endpoint.startsWith("pending-")
|
|
311
|
+
&& lease.endpoint !== "observe-only"
|
|
312
|
+
&& existsSync(lease.endpoint);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function leaseRespondsToPing(lease: ZobLiveTeamAgentLease): Promise<boolean> {
|
|
316
|
+
if (!leaseHasLocalSocketEndpoint(lease)) return false;
|
|
317
|
+
try {
|
|
318
|
+
const response = await pingZobLocalEndpoint(lease.endpoint, `zteam-agent-lease-ping:${lease.teamId}:${lease.agentId}:${Date.now()}`);
|
|
319
|
+
return response.type === "pong" || response.type === "ack";
|
|
320
|
+
} catch {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
112
325
|
export function pruneExpiredZobLivePeers(repoRoot: string, input: { teamName?: string; nowMs?: number; retentionMs?: number } = {}): { schema: "zob.live-registry-prune.v1"; pruned: number; retained: number; retentionMs: number; bodyStored: false } {
|
|
113
326
|
const { dir } = projectAgentsDir(repoRoot);
|
|
114
327
|
const nowMs = input.nowMs ?? Date.now();
|
|
115
328
|
const retentionMs = boundedOfflinePeerRetentionMs(input.retentionMs);
|
|
116
329
|
let pruned = 0;
|
|
117
330
|
let retained = 0;
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
331
|
+
if (existsSync(dir)) {
|
|
332
|
+
for (const entry of readdirSync(dir).filter((name) => name.endsWith(".json"))) {
|
|
333
|
+
const filePath = join(dir, entry);
|
|
334
|
+
try {
|
|
335
|
+
const peer = parsePeerCard(JSON.parse(readFileSync(filePath, "utf8")) as unknown);
|
|
336
|
+
if (!peer || (input.teamName && peer.team !== input.teamName)) {
|
|
337
|
+
retained += 1;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (offlinePeerExpired(peer, nowMs, retentionMs)) {
|
|
341
|
+
unlinkSync(filePath);
|
|
342
|
+
pruned += 1;
|
|
343
|
+
} else {
|
|
344
|
+
retained += 1;
|
|
345
|
+
}
|
|
346
|
+
} catch {
|
|
131
347
|
retained += 1;
|
|
132
348
|
}
|
|
133
|
-
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const leasePrune = pruneExpiredZobLiveTeamAgentLeases(repoRoot, input);
|
|
352
|
+
return { schema: "zob.live-registry-prune.v1", pruned: pruned + leasePrune.pruned, retained: retained + leasePrune.retained, retentionMs, bodyStored: false };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function pruneExpiredZobLiveTeamAgentLeases(repoRoot: string, input: { teamName?: string; nowMs?: number; retentionMs?: number } = {}): { schema: "zob.live-team-agent-lease-prune.v1"; pruned: number; retained: number; retentionMs: number; bodyStored: false } {
|
|
356
|
+
const { dir } = projectLeasesDir(repoRoot);
|
|
357
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
358
|
+
const retentionMs = boundedOfflinePeerRetentionMs(input.retentionMs);
|
|
359
|
+
let pruned = 0;
|
|
360
|
+
let retained = 0;
|
|
361
|
+
if (!existsSync(dir)) return { schema: "zob.live-team-agent-lease-prune.v1", pruned, retained, retentionMs, bodyStored: false };
|
|
362
|
+
for (const entry of readdirSync(dir).filter((name) => name.endsWith(".json"))) {
|
|
363
|
+
const filePath = join(dir, entry);
|
|
364
|
+
const lease = readLeaseAtPath(filePath);
|
|
365
|
+
if (!lease || (input.teamName && lease.teamId !== input.teamName)) {
|
|
366
|
+
retained += 1;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (leaseExpired(lease, nowMs, retentionMs)) {
|
|
370
|
+
unlinkSync(filePath);
|
|
371
|
+
pruned += 1;
|
|
372
|
+
} else {
|
|
134
373
|
retained += 1;
|
|
135
374
|
}
|
|
136
375
|
}
|
|
137
|
-
return { schema: "zob.live-
|
|
376
|
+
return { schema: "zob.live-team-agent-lease-prune.v1", pruned, retained, retentionMs, bodyStored: false };
|
|
138
377
|
}
|
|
139
378
|
|
|
140
379
|
export function writeZobLivePeerCard(repoRoot: string, peer: ZobLivePeerCard): ZobLivePeerCard {
|
|
@@ -142,7 +381,7 @@ export function writeZobLivePeerCard(repoRoot: string, peer: ZobLivePeerCard): Z
|
|
|
142
381
|
if (peer.bodyStored !== false) throw new Error("ZOB live peer card bodyStored must be false");
|
|
143
382
|
const { dir } = projectAgentsDir(repoRoot);
|
|
144
383
|
mkdirSync(dir, { recursive: true });
|
|
145
|
-
writeFileSync(peerPath(repoRoot, peer), `${JSON.stringify(peer, null, 2)}\n`, "utf8");
|
|
384
|
+
writeFileSync(peerPath(repoRoot, peer), `${JSON.stringify({ ...peer, projectId: buildZobComsProjectId(repoRoot) }, null, 2)}\n`, "utf8");
|
|
146
385
|
return peer;
|
|
147
386
|
}
|
|
148
387
|
|
|
@@ -155,18 +394,112 @@ export function writeZobLivePeerCardToProjectId(peer: ZobLivePeerCard): ZobLiveP
|
|
|
155
394
|
return peer;
|
|
156
395
|
}
|
|
157
396
|
|
|
397
|
+
export function writeZobLiveTeamAgentLease(repoRoot: string, peer: ZobLivePeerCard, input: { reason?: string; nowMs?: number } = {}): ZobLiveTeamAgentLease {
|
|
398
|
+
const filePath = leasePath(repoRoot, peer);
|
|
399
|
+
const existing = readLeaseAtPath(filePath);
|
|
400
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
401
|
+
if (existing && !sameLeaseOwner(existing, peer) && !leaseExpired(existing, nowMs)) return existing;
|
|
402
|
+
const ownedExisting = existing && sameLeaseOwner(existing, peer) ? existing : undefined;
|
|
403
|
+
return writeLeaseAtPath(filePath, renewTeamAgentLease(repoRoot, ownedExisting, peer, { nowMs }));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export async function claimZobLiveTeamAgentLease(repoRoot: string, peer: ZobLivePeerCard, input: { reason?: string; nowMs?: number } = {}): Promise<
|
|
407
|
+
| { ok: true; status: "acquired" | "renewed" | "reclaimed"; lease: ZobLiveTeamAgentLease; previousOwnerHash?: string; pingChecked: boolean; bodyStored: false }
|
|
408
|
+
| { ok: false; status: "blocked_live_owner"; lease: ZobLiveTeamAgentLease; ownerHash: string; reason: string; pingChecked: true; bodyStored: false }
|
|
409
|
+
> {
|
|
410
|
+
const filePath = leasePath(repoRoot, peer);
|
|
411
|
+
const existing = readLeaseAtPath(filePath);
|
|
412
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
413
|
+
if (!existing) {
|
|
414
|
+
const lease = writeLeaseAtPath(filePath, buildTeamAgentLease(repoRoot, peer, { nowMs }));
|
|
415
|
+
return { ok: true, status: "acquired", lease, pingChecked: false, bodyStored: false };
|
|
416
|
+
}
|
|
417
|
+
if (sameLeaseOwner(existing, peer)) {
|
|
418
|
+
const lease = writeLeaseAtPath(filePath, renewTeamAgentLease(repoRoot, existing, peer, { nowMs }));
|
|
419
|
+
return { ok: true, status: "renewed", lease, pingChecked: false, bodyStored: false };
|
|
420
|
+
}
|
|
421
|
+
const responsive = await leaseRespondsToPing(existing);
|
|
422
|
+
if (responsive) {
|
|
423
|
+
return {
|
|
424
|
+
ok: false,
|
|
425
|
+
status: "blocked_live_owner",
|
|
426
|
+
lease: existing,
|
|
427
|
+
ownerHash: existing.leaseOwnerHash,
|
|
428
|
+
reason: `stable team-agent lease for ${existing.teamId}/${existing.agentId} is held by a responsive live endpoint`,
|
|
429
|
+
pingChecked: true,
|
|
430
|
+
bodyStored: false,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
const lease = writeLeaseAtPath(filePath, buildTeamAgentLease(repoRoot, peer, { nowMs }));
|
|
434
|
+
return { ok: true, status: "reclaimed", lease, previousOwnerHash: existing.leaseOwnerHash, pingChecked: true, bodyStored: false };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function releaseZobLiveTeamAgentLease(repoRoot: string, peer: ZobLivePeerCard, input: { reason?: string } = {}): { schema: "zob.live-team-agent-lease-release.v1"; released: boolean; reason: "released" | "not_found" | "owner_mismatch"; teamId: string; agentId: string; leaseOwnerHash?: string; bodyStored: false } {
|
|
438
|
+
void input;
|
|
439
|
+
const filePath = leasePath(repoRoot, peer);
|
|
440
|
+
const existing = readLeaseAtPath(filePath);
|
|
441
|
+
if (!existing) return { schema: "zob.live-team-agent-lease-release.v1", released: false, reason: "not_found", teamId: peer.team, agentId: peer.roleId, bodyStored: false };
|
|
442
|
+
if (!sameLeaseOwner(existing, peer)) return { schema: "zob.live-team-agent-lease-release.v1", released: false, reason: "owner_mismatch", teamId: peer.team, agentId: peer.roleId, leaseOwnerHash: existing.leaseOwnerHash, bodyStored: false };
|
|
443
|
+
unlinkSync(filePath);
|
|
444
|
+
return { schema: "zob.live-team-agent-lease-release.v1", released: true, reason: "released", teamId: peer.team, agentId: peer.roleId, leaseOwnerHash: existing.leaseOwnerHash, bodyStored: false };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function ownsZobLiveTeamAgentLease(repoRoot: string, peer: ZobLivePeerCard): { schema: "zob.live-team-agent-lease-ownership.v1"; owned: boolean; reason: "owned" | "not_found" | "owner_mismatch" | "expired"; teamId: string; agentId: string; leaseOwnerHash?: string; bodyStored: false } {
|
|
448
|
+
const filePath = leasePath(repoRoot, peer);
|
|
449
|
+
const existing = readLeaseAtPath(filePath);
|
|
450
|
+
if (!existing) return { schema: "zob.live-team-agent-lease-ownership.v1", owned: false, reason: "not_found", teamId: peer.team, agentId: peer.roleId, bodyStored: false };
|
|
451
|
+
if (leaseExpired(existing, Date.now())) return { schema: "zob.live-team-agent-lease-ownership.v1", owned: false, reason: "expired", teamId: peer.team, agentId: peer.roleId, leaseOwnerHash: existing.leaseOwnerHash, bodyStored: false };
|
|
452
|
+
if (!sameLeaseOwner(existing, peer)) return { schema: "zob.live-team-agent-lease-ownership.v1", owned: false, reason: "owner_mismatch", teamId: peer.team, agentId: peer.roleId, leaseOwnerHash: existing.leaseOwnerHash, bodyStored: false };
|
|
453
|
+
return { schema: "zob.live-team-agent-lease-ownership.v1", owned: true, reason: "owned", teamId: peer.team, agentId: peer.roleId, leaseOwnerHash: existing.leaseOwnerHash, bodyStored: false };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export async function retireInactiveZobLiveTeamAgentLeases(repoRoot: string, input: { teamName: string; agentIds: string[]; nowMs?: number }): Promise<{ schema: "zob.live-team-agent-lease-retire.v1"; teamName: string; checked: number; retired: number; retainedLive: number; missing: number; errorHashes: string[]; bodyStored: false }> {
|
|
457
|
+
const errors: string[] = [];
|
|
458
|
+
let checked = 0;
|
|
459
|
+
let retired = 0;
|
|
460
|
+
let retainedLive = 0;
|
|
461
|
+
let missing = 0;
|
|
462
|
+
const uniqueAgentIds = [...new Set(input.agentIds.filter((agentId) => agentId.trim().length > 0))];
|
|
463
|
+
for (const agentId of uniqueAgentIds) {
|
|
464
|
+
const filePath = leasePathForTeamAgent(repoRoot, input.teamName, agentId);
|
|
465
|
+
const lease = readLeaseAtPath(filePath);
|
|
466
|
+
if (!lease) {
|
|
467
|
+
missing += 1;
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
checked += 1;
|
|
471
|
+
try {
|
|
472
|
+
const live = await leaseRespondsToPing(lease);
|
|
473
|
+
if (live) {
|
|
474
|
+
retainedLive += 1;
|
|
475
|
+
} else {
|
|
476
|
+
unlinkSync(filePath);
|
|
477
|
+
retired += 1;
|
|
478
|
+
}
|
|
479
|
+
} catch (error) {
|
|
480
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return { schema: "zob.live-team-agent-lease-retire.v1", teamName: input.teamName, checked, retired, retainedLive, missing, errorHashes: errors.map((error) => sha256(error)), bodyStored: false };
|
|
484
|
+
}
|
|
485
|
+
|
|
158
486
|
export function registerCurrentZobLivePeer(repoRoot: string, teamName = "zob-core"): ZobLivePeerCard | undefined {
|
|
159
487
|
const policy = readZobComsV2Policy(repoRoot);
|
|
160
488
|
if (!zobComsRegistryEnabled(policy)) return undefined;
|
|
161
489
|
const team = loadTeamDefinition(repoRoot, teamName);
|
|
162
490
|
const errors = [...team.errors, ...validateTeamDefinition(repoRoot, team.definition)];
|
|
163
491
|
if (errors.length > 0 || !team.definition) throw new Error(`Cannot register ZOB live peer: ${errors.join("; ")}`);
|
|
164
|
-
|
|
492
|
+
const peer = writeZobLivePeerCard(repoRoot, buildCurrentZobLivePeerCard(repoRoot, team.definition, policy));
|
|
493
|
+
writeZobLiveTeamAgentLease(repoRoot, peer, { reason: "register_current" });
|
|
494
|
+
return peer;
|
|
165
495
|
}
|
|
166
496
|
|
|
167
497
|
export function touchCurrentZobLivePeer(repoRoot: string, teamName = "zob-core"): ZobLivePeerCard | undefined {
|
|
168
498
|
const peer = registerCurrentZobLivePeer(repoRoot, teamName);
|
|
169
|
-
|
|
499
|
+
if (!peer) return undefined;
|
|
500
|
+
const touched = writeZobLivePeerCard(repoRoot, { ...peer, heartbeatAt: new Date().toISOString(), status: "online" });
|
|
501
|
+
writeZobLiveTeamAgentLease(repoRoot, touched, { reason: "touch_current" });
|
|
502
|
+
return touched;
|
|
170
503
|
}
|
|
171
504
|
|
|
172
505
|
export function unregisterCurrentZobLivePeer(repoRoot: string, teamName = "zob-core"): ZobLivePeerCard | undefined {
|
|
@@ -174,17 +507,25 @@ export function unregisterCurrentZobLivePeer(repoRoot: string, teamName = "zob-c
|
|
|
174
507
|
const team = loadTeamDefinition(repoRoot, teamName);
|
|
175
508
|
if (!team.definition || !zobComsRegistryEnabled(policy)) return undefined;
|
|
176
509
|
const peer = buildCurrentZobLivePeerCard(repoRoot, team.definition, policy);
|
|
510
|
+
releaseZobLiveTeamAgentLease(repoRoot, peer, { reason: "unregister_current" });
|
|
177
511
|
return writeZobLivePeerCard(repoRoot, { ...peer, heartbeatAt: new Date().toISOString(), status: "offline" });
|
|
178
512
|
}
|
|
179
513
|
|
|
180
514
|
export function readZobLiveRegistrySnapshot(repoRoot: string, teamName?: string): ZobLiveRegistrySnapshot {
|
|
181
|
-
const { dir, projectId, kind } =
|
|
182
|
-
|
|
515
|
+
const { dir, projectId, kind } = projectLeasesDir(repoRoot);
|
|
516
|
+
const nowMs = Date.now();
|
|
517
|
+
if (existsSync(dir)) return buildSnapshot(projectId, kind, readLeasesFromDir(dir, nowMs, teamName), teamName);
|
|
518
|
+
const agents = projectAgentsDir(repoRoot);
|
|
519
|
+
return buildSnapshot(agents.projectId, agents.kind, readPeerCardsFromAgentsDir(agents.dir, nowMs, teamName), teamName);
|
|
183
520
|
}
|
|
184
521
|
|
|
185
522
|
export function readZobLiveRegistryAllProjectsSnapshot(repoRoot: string, teamName?: string): ZobLiveRegistrySnapshot {
|
|
186
523
|
const { projectId, kind } = projectAgentsDir(repoRoot);
|
|
187
524
|
const nowMs = Date.now();
|
|
188
|
-
const
|
|
525
|
+
const leaseDirs = allProjectLeasesDirs();
|
|
526
|
+
const hasLeaseDomain = leaseDirs.some((dir) => existsSync(dir));
|
|
527
|
+
const peers = hasLeaseDomain
|
|
528
|
+
? leaseDirs.flatMap((dir) => readLeasesFromDir(dir, nowMs, teamName))
|
|
529
|
+
: allProjectAgentsDirs().flatMap((dir) => readPeerCardsFromAgentsDir(dir, nowMs, teamName));
|
|
189
530
|
return buildSnapshot(projectId, kind, peers, teamName);
|
|
190
531
|
}
|
|
@@ -114,6 +114,46 @@ export interface ZobLivePeerCard {
|
|
|
114
114
|
bodyStored: false;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
export interface ZobLiveTeamAgentLease {
|
|
118
|
+
schema: "zob.live-team-agent-lease.v1";
|
|
119
|
+
projectId: string;
|
|
120
|
+
teamId: string;
|
|
121
|
+
agentId: string;
|
|
122
|
+
roleId: string;
|
|
123
|
+
roleType: ZobLiveRoleType;
|
|
124
|
+
leadId?: string;
|
|
125
|
+
agent: string;
|
|
126
|
+
sessionId: string;
|
|
127
|
+
sessionHash: string;
|
|
128
|
+
leaseOwnerId: string;
|
|
129
|
+
leaseOwnerHash: string;
|
|
130
|
+
transport: ZobLiveTransportKind;
|
|
131
|
+
endpoint: string;
|
|
132
|
+
endpointHash: string;
|
|
133
|
+
cwdHash: string;
|
|
134
|
+
pid?: number;
|
|
135
|
+
startedAt: string;
|
|
136
|
+
heartbeatAt: string;
|
|
137
|
+
leasedAt: string;
|
|
138
|
+
renewedAt: string;
|
|
139
|
+
expiresAt: string;
|
|
140
|
+
contextUsedPct: number;
|
|
141
|
+
queueDepth: number;
|
|
142
|
+
status: ZobLivePeerStatus;
|
|
143
|
+
zpeerRoomId?: string;
|
|
144
|
+
zpeerAlias?: string;
|
|
145
|
+
zpeerActiveRoomId?: string;
|
|
146
|
+
zpeerMemberships?: ZpeerRoomMembership[];
|
|
147
|
+
zpeerLocalOnly?: true;
|
|
148
|
+
staleAfterMs: number;
|
|
149
|
+
offlineAfterMs: number;
|
|
150
|
+
stableLease: true;
|
|
151
|
+
exclusiveBy: "teamId+agentId";
|
|
152
|
+
localOnly: true;
|
|
153
|
+
networkEnabled: false;
|
|
154
|
+
bodyStored: false;
|
|
155
|
+
}
|
|
156
|
+
|
|
117
157
|
export interface ZobLiveRegistrySnapshot {
|
|
118
158
|
schema: "zob.live-registry-snapshot.v1";
|
|
119
159
|
projectId: string;
|
|
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
|
|
4
4
|
import { buildZobComsProjectId } from "./identity.js";
|
|
5
5
|
import { buildZobLiveEnvelope } from "./envelope.js";
|
|
6
6
|
import { sendZobLocalEnvelope } from "./local-transport.js";
|
|
7
|
-
import { readZobLiveRegistryAllProjectsSnapshot, writeZobLivePeerCard, writeZobLivePeerCardToProjectId } from "./registry.js";
|
|
7
|
+
import { ownsZobLiveTeamAgentLease, readZobLiveRegistryAllProjectsSnapshot, writeZobLivePeerCard, writeZobLivePeerCardToProjectId, writeZobLiveTeamAgentLease } from "./registry.js";
|
|
8
8
|
import type { ZobLivePeerCard, ZobLivePeerStatus, ZpeerRoomMembership, ZpeerRoomMembershipRole } from "./types.js";
|
|
9
9
|
import { validateZobComsEdge } from "../../topology/coms.js";
|
|
10
10
|
import { loadTeamDefinition, validateTeamDefinition } from "../../topology/teams.js";
|
|
@@ -233,7 +233,9 @@ export function ensureZpeerFields(repoRoot: string, peer: ZobLivePeerCard, roomI
|
|
|
233
233
|
export function refreshZpeerSelf(repoRoot: string, peer: ZobLivePeerCard, roomId?: string, alias?: string, restoredMemberships?: ZpeerRoomMembership[]): ZobLivePeerCard {
|
|
234
234
|
const ensured = ensureZpeerFields(repoRoot, peer, roomId, alias, restoredMemberships);
|
|
235
235
|
if (!hasLocalSocketEndpointEvidence(ensured)) return ensured;
|
|
236
|
-
|
|
236
|
+
const refreshed = writeZobLivePeerCard(repoRoot, { ...ensured, heartbeatAt: new Date().toISOString(), status: "online" });
|
|
237
|
+
writeZobLiveTeamAgentLease(repoRoot, refreshed, { reason: "zpeer_refresh" });
|
|
238
|
+
return refreshed;
|
|
237
239
|
}
|
|
238
240
|
|
|
239
241
|
type ZobLiveRegistrySnapshotValue = ReturnType<typeof readZobLiveRegistryAllProjectsSnapshot>;
|
|
@@ -477,6 +479,8 @@ export async function sendZpeerPrompt(repoRoot: string, self: ZobLivePeerCard, t
|
|
|
477
479
|
};
|
|
478
480
|
|
|
479
481
|
if (!selfMembership) return finish("attempt", { status: "blocked", reason: `current peer is not a member of room '${roomId}'`, targetAlias: targetAlias ?? undefined, taskHash, bodyStored: false });
|
|
482
|
+
const leaseOwnership = ownsZobLiveTeamAgentLease(repoRoot, self);
|
|
483
|
+
if (!leaseOwnership.owned) return finish("attempt", { status: "blocked", reason: `current peer does not own stable team-agent lease (${leaseOwnership.reason})`, targetAlias: targetAlias ?? undefined, taskHash, bodyStored: false });
|
|
480
484
|
if (selfMembership.role === "observer") return finish("attempt", { status: "blocked", reason: `current peer is observer-only in room '${roomId}'`, targetAlias: targetAlias ?? undefined, taskHash, bodyStored: false });
|
|
481
485
|
if (!targetAlias) return finish("attempt", { status: "blocked", reason: "invalid target alias", bodyStored: false });
|
|
482
486
|
if (!transientPrompt.trim()) return finish("attempt", { status: "blocked", reason: "empty peer prompt", targetAlias, bodyStored: false });
|