@prjct.app/pi-team 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/CONTRIBUTING.md +2 -1
- package/README.md +23 -178
- package/docs/architecture.md +36 -173
- package/package.json +10 -4
- package/src/commands/team-command.ts +37 -0
- package/src/domain/lease.ts +54 -0
- package/src/domain/member.ts +58 -0
- package/src/domain/message.ts +91 -0
- package/src/domain/request.ts +67 -0
- package/src/domain/team.ts +71 -0
- package/src/dynamic/domain.ts +110 -0
- package/src/dynamic/memory.ts +38 -0
- package/src/dynamic/panel.ts +155 -0
- package/src/dynamic/peer-log.ts +39 -0
- package/src/dynamic/runner.ts +196 -0
- package/src/dynamic/service.ts +292 -0
- package/src/dynamic/store.ts +57 -0
- package/src/dynamic/view.ts +21 -0
- package/src/dynamic/worker.ts +210 -0
- package/src/dynamic/workspace.ts +43 -0
- package/src/index.ts +204 -679
- package/src/process-identity.ts +68 -0
- package/src/runtime/delivery.ts +326 -0
- package/src/runtime/membership.ts +212 -0
- package/src/runtime/presence.ts +98 -0
- package/src/runtime/purge.ts +39 -0
- package/src/runtime/reconciler.ts +112 -0
- package/src/runtime/requests.ts +353 -0
- package/src/runtime/resources.ts +117 -0
- package/src/runtime/team-runtime.ts +47 -0
- package/src/runtime/team-tool.ts +191 -0
- package/src/storage/atomic.ts +347 -0
- package/src/storage/inbox-store.ts +290 -0
- package/src/storage/lease-store.ts +158 -0
- package/src/storage/paths.ts +76 -0
- package/src/storage/receipt-store.ts +117 -0
- package/src/storage/team-store.ts +190 -0
- package/src/supervisor/control-protocol.ts +125 -0
- package/src/supervisor/runtime-store.ts +231 -0
- package/src/supervisor/shutdown.ts +141 -0
- package/src/supervisor/supervisor.ts +657 -0
- package/src/supervisor/tmux-adapter.ts +192 -0
- package/src/supervisor/worker-bootstrap.ts +43 -0
- package/src/supervisor/worker-client.ts +233 -0
- package/src/ui/team-dashboard.ts +179 -0
- package/src/mailbox.ts +0 -536
- package/src/schema.ts +0 -25
- package/src/store.ts +0 -247
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { appendFile, chmod, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { metadata } from './domain.ts';
|
|
4
|
+
|
|
5
|
+
/** Kinds an Expert may send another Expert directly. */
|
|
6
|
+
export const PEER_KINDS = ['blocker', 'question', 'info', 'handoff'] as const;
|
|
7
|
+
export type PeerKind = (typeof PEER_KINDS)[number];
|
|
8
|
+
|
|
9
|
+
/** One Expert-to-Expert message, as the orchestrator and /team show it. */
|
|
10
|
+
export type PeerEntry = { readonly at: string; readonly from: string; readonly to: string; readonly kind: PeerKind; readonly body: string };
|
|
11
|
+
|
|
12
|
+
const LOG = 'peer-messages.jsonl';
|
|
13
|
+
const KEEP = 200;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Append-only record of direct messages, so the person (and the orchestrator)
|
|
17
|
+
* can see who talked to whom. The message itself travels through the durable
|
|
18
|
+
* inbox; this is only the trace. Private to the user (0600).
|
|
19
|
+
*/
|
|
20
|
+
export async function recordPeerMessage(directory: string, entry: PeerEntry): Promise<void> {
|
|
21
|
+
const path = join(directory, LOG);
|
|
22
|
+
const line = JSON.stringify({ ...entry, body: metadata(entry.body, 600) });
|
|
23
|
+
await appendFile(path, `${line}\n`, { mode: 0o600 });
|
|
24
|
+
await chmod(path, 0o600).catch(() => {});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The most recent direct messages, newest last. */
|
|
28
|
+
export async function recentPeerMessages(directory: string, limit = 20): Promise<PeerEntry[]> {
|
|
29
|
+
const text = await readFile(join(directory, LOG), 'utf8').catch(() => '');
|
|
30
|
+
return text.split('\n').filter(Boolean).slice(-KEEP).flatMap(line => {
|
|
31
|
+
try {
|
|
32
|
+
const value = JSON.parse(line) as PeerEntry;
|
|
33
|
+
return typeof value.from === 'string' && typeof value.to === 'string' && (PEER_KINDS as readonly string[]).includes(value.kind) ? [value] : [];
|
|
34
|
+
} catch { return []; }
|
|
35
|
+
}).slice(-limit);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** "fty-4 → fty-26 blocker: needs the /api/demo route merged first" */
|
|
39
|
+
export const peerLine = (entry: PeerEntry): string => `${entry.from} → ${entry.to} ${entry.kind}: ${entry.body.replace(/\s+/g, ' ').slice(0, 200)}`;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { lstat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import { ensurePrivateTree, createAtomicJson } from '../storage/atomic.ts';
|
|
6
|
+
import { TeamPaths } from '../storage/paths.ts';
|
|
7
|
+
import { TeamRuntime } from '../runtime/team-runtime.ts';
|
|
8
|
+
import type { Lease } from '../domain/lease.ts';
|
|
9
|
+
import type { Membership } from '../runtime/membership.ts';
|
|
10
|
+
import { TeamSupervisor, type SupervisorOptions } from '../supervisor/supervisor.ts';
|
|
11
|
+
import type { ExpertExecution, ExpertOutcome, ExpertRunner } from './service.ts';
|
|
12
|
+
import { DynamicStore } from './store.ts';
|
|
13
|
+
import { metadata } from './domain.ts';
|
|
14
|
+
import { expertWorkspace, isGitCheckout } from './workspace.ts';
|
|
15
|
+
import { expertMemory, expertStance, type ExpertStance } from './memory.ts';
|
|
16
|
+
|
|
17
|
+
export type SupervisorPort = Pick<TeamSupervisor, 'launch' | 'stop' | 'close' | 'owner'>;
|
|
18
|
+
type RunnerSetup = { readonly membership: Membership; readonly supervisor: SupervisorPort };
|
|
19
|
+
export type RunnerOptions = {
|
|
20
|
+
readonly runtime?: TeamRuntime;
|
|
21
|
+
readonly supervisor?: (options: SupervisorOptions) => SupervisorPort;
|
|
22
|
+
readonly command?: readonly [string, ...string[]];
|
|
23
|
+
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
24
|
+
readonly pollMs?: number;
|
|
25
|
+
readonly timeoutMs?: number;
|
|
26
|
+
/** Where a write-capable Expert works. Defaults to its own Git worktree when the project is a checkout. */
|
|
27
|
+
readonly workspace?: (input: ExpertExecution) => Promise<string>;
|
|
28
|
+
/** Project memory for the Expert's stance and task. Defaults to the view pi-memory publishes. */
|
|
29
|
+
readonly memory?: (stance: ExpertStance, query: string) => Promise<string>;
|
|
30
|
+
};
|
|
31
|
+
const WRITE_TOOLS = ['edit', 'write', 'bash'];
|
|
32
|
+
// The compiled local build ships index.js; source checkouts execute index.ts directly.
|
|
33
|
+
const ENTRY = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? '../../index.ts' : '../../index.js', import.meta.url));
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Why an Expert did not start, safe to show: a failed child command's message
|
|
37
|
+
* repeats its arguments (tmux -e carries control and lease tokens), so it is
|
|
38
|
+
* replaced, and anything token-shaped is masked.
|
|
39
|
+
*/
|
|
40
|
+
export function safeReason(error: unknown): string {
|
|
41
|
+
if (!(error instanceof Error)) return 'unknown error';
|
|
42
|
+
const code = (error as { code?: unknown }).code;
|
|
43
|
+
const text = /Command failed|spawn|tmux\s/i.test(error.message) ? 'tmux could not start the Expert session' : error.message;
|
|
44
|
+
return metadata(`${text.replace(/[A-Za-z0-9_+/=-]{32,}/g, '…')}${typeof code === 'string' ? ` (${code})` : ''}`, 240);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Durable request/reply transport; a launch or a model turn alone is never success. */
|
|
48
|
+
export class ProductionExpertRunner implements ExpertRunner {
|
|
49
|
+
readonly runtime: TeamRuntime;
|
|
50
|
+
private setup?: Promise<RunnerSetup>;
|
|
51
|
+
private refresh?: Promise<RunnerSetup>;
|
|
52
|
+
private readonly closing = new AbortController();
|
|
53
|
+
private readonly pending = new Set<Promise<ExpertOutcome>>();
|
|
54
|
+
constructor(readonly store: DynamicStore, private readonly options: RunnerOptions = {}) {
|
|
55
|
+
this.runtime = options.runtime ?? new TeamRuntime(new TeamPaths(join(store.root, 'transport')));
|
|
56
|
+
}
|
|
57
|
+
private initialize(input: ExpertExecution) {
|
|
58
|
+
return this.setup ??= (async () => {
|
|
59
|
+
const at = new Date().toISOString();
|
|
60
|
+
if (!await this.runtime.teams.read(input.teamId)) {
|
|
61
|
+
await this.runtime.teams.create({ schemaVersion: 2, teamId: input.teamId, state: 'open', createdAt: at, updatedAt: at });
|
|
62
|
+
}
|
|
63
|
+
const membership = await this.runtime.memberships.join({ teamId: input.teamId, alias: 'orchestrator',
|
|
64
|
+
sessionId: input.owner.sessionId, cwd: input.projectPath, kind: 'external' });
|
|
65
|
+
const options: SupervisorOptions = { teamId: input.teamId, ownerSessionId: input.owner.sessionId,
|
|
66
|
+
ownerInstanceId: input.owner.instanceId, ownerEpoch: input.owner.epoch,
|
|
67
|
+
paths: this.runtime.paths, teams: this.runtime.teams, runtimes: this.runtime.runtimes };
|
|
68
|
+
return { membership, supervisor: this.options.supervisor?.(options) ?? new TeamSupervisor(options) };
|
|
69
|
+
})();
|
|
70
|
+
}
|
|
71
|
+
private async activeSetup(input: ExpertExecution): Promise<RunnerSetup> {
|
|
72
|
+
const setup = await this.initialize(input);
|
|
73
|
+
try {
|
|
74
|
+
await this.runtime.memberships.heartbeat(setup.membership);
|
|
75
|
+
return setup;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if ((error as { code?: string }).code !== 'FENCED') throw error;
|
|
78
|
+
this.refresh ??= (async () => {
|
|
79
|
+
const membership = await this.runtime.memberships.join({ teamId: input.teamId, alias: 'orchestrator',
|
|
80
|
+
sessionId: input.owner.sessionId, cwd: input.projectPath, kind: 'external' });
|
|
81
|
+
const refreshed = { ...setup, membership };
|
|
82
|
+
this.setup = Promise.resolve(refreshed);
|
|
83
|
+
return refreshed;
|
|
84
|
+
})().finally(() => { this.refresh = undefined; });
|
|
85
|
+
return this.refresh;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
private readonly defaultWorkspace = async (input: ExpertExecution): Promise<string> =>
|
|
89
|
+
await isGitCheckout(input.projectPath)
|
|
90
|
+
? expertWorkspace({ root: this.store.root, teamId: input.teamId, expertId: input.expert.id, projectPath: input.projectPath })
|
|
91
|
+
: input.projectPath;
|
|
92
|
+
run(input: ExpertExecution, signal: AbortSignal): Promise<ExpertOutcome> {
|
|
93
|
+
const operation = this.execute(input, AbortSignal.any([signal, this.closing.signal, AbortSignal.timeout(this.options.timeoutMs ?? 600_000)]));
|
|
94
|
+
this.pending.add(operation);
|
|
95
|
+
void operation.finally(() => this.pending.delete(operation)).catch(() => {});
|
|
96
|
+
return operation;
|
|
97
|
+
}
|
|
98
|
+
private async execute(input: ExpertExecution, signal: AbortSignal): Promise<ExpertOutcome> {
|
|
99
|
+
const state: { runtimeId?: string; requestId?: string; peer?: Membership; supervisor?: SupervisorPort; owner?: Membership;
|
|
100
|
+
resource?: Lease; outcome: ExpertOutcome } = { outcome: { status: 'failed', summary: 'Expert execution failed; inspect /team doctor.', stopped: false } };
|
|
101
|
+
try {
|
|
102
|
+
signal.throwIfAborted();
|
|
103
|
+
const { membership, supervisor } = await this.activeSetup(input);
|
|
104
|
+
state.owner = membership; state.supervisor = supervisor;
|
|
105
|
+
await ensurePrivateTree(this.store.root, 'projects', input.teamId, 'sessions');
|
|
106
|
+
const session = this.store.sessionPath(input.teamId, input.expert.sessionRef);
|
|
107
|
+
const info = await lstat(session).catch(error => {
|
|
108
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
109
|
+
throw error;
|
|
110
|
+
});
|
|
111
|
+
if (info && (!info.isFile() || info.isSymbolicLink() || (info.mode & 0o077) !== 0)) throw new Error('Unsafe Expert session.');
|
|
112
|
+
const writer = input.expert.policy.tools.some(tool => WRITE_TOOLS.includes(tool));
|
|
113
|
+
const cwd = writer ? await (this.options.workspace ?? this.defaultWorkspace)(input) : input.projectPath;
|
|
114
|
+
if (!info) await createAtomicJson(session, { type: 'session', version: 3, id: input.expert.sessionRef,
|
|
115
|
+
timestamp: new Date().toISOString(), cwd });
|
|
116
|
+
state.peer = await this.runtime.memberships.join({ teamId: input.teamId, alias: `e-${input.expert.id}`,
|
|
117
|
+
sessionId: input.expert.sessionRef, cwd, kind: 'supervised' });
|
|
118
|
+
if (writer) {
|
|
119
|
+
state.resource = await this.runtime.resources.claim(state.peer, '.', undefined, signal);
|
|
120
|
+
}
|
|
121
|
+
signal.throwIfAborted();
|
|
122
|
+
const command: readonly [string, ...string[]] = this.options.command ?? (process.argv[1]
|
|
123
|
+
? [process.execPath, process.argv[1]] : ['pi']);
|
|
124
|
+
const launched = await supervisor.launch({ memberId: state.peer.memberId, cwd,
|
|
125
|
+
workerMembership: state.peer, autoRequests: true, environment: this.options.environment ?? process.env,
|
|
126
|
+
command: [...command,
|
|
127
|
+
'--no-approve', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-context-files',
|
|
128
|
+
'--extension', ENTRY, '--team-store', this.store.root,
|
|
129
|
+
'--session', session, '--tools', [...input.expert.policy.tools, 'team_reply', 'team_peers', 'team_message'].join(','),
|
|
130
|
+
'--name', `expert:${input.expert.role}`] as [string, ...string[]] });
|
|
131
|
+
state.runtimeId = launched.runtimeId;
|
|
132
|
+
signal.throwIfAborted();
|
|
133
|
+
const memory = await (this.options.memory ?? expertMemory)(expertStance(input.expert.role, input.expert.policy.tools),
|
|
134
|
+
input.assignment.task).catch(() => '');
|
|
135
|
+
signal.throwIfAborted();
|
|
136
|
+
const request = await this.runtime.requests.send(membership, { to: state.peer.alias, kind: 'request',
|
|
137
|
+
body: JSON.stringify({ assignmentId: input.assignment.id, generation: input.assignment.generation,
|
|
138
|
+
ownerEpoch: input.assignment.ownerEpoch, task: input.assignment.task, ...(memory ? { memory } : {}) }), signal });
|
|
139
|
+
state.requestId = request.messageId;
|
|
140
|
+
const heartbeat = { at: 0 };
|
|
141
|
+
while (!signal.aborted) {
|
|
142
|
+
if (Date.now() - heartbeat.at >= 10_000) {
|
|
143
|
+
await this.runtime.memberships.heartbeat(membership);
|
|
144
|
+
if (state.resource && state.peer) state.resource = await this.runtime.resources.renew(state.peer, '.',
|
|
145
|
+
state.resource.token, state.resource.generation);
|
|
146
|
+
heartbeat.at = Date.now();
|
|
147
|
+
}
|
|
148
|
+
const receipt = await this.runtime.receipts.read(input.teamId, state.peer.memberId, request.messageId);
|
|
149
|
+
if (receipt?.status === 'replied') {
|
|
150
|
+
const reply = await this.runtime.requests.receive(membership, `reply-${request.messageId}`, signal);
|
|
151
|
+
if (!reply.discarded && reply.message?.kind === 'reply' && reply.message.requestId === request.messageId &&
|
|
152
|
+
reply.message.fromMemberId === state.peer.memberId && reply.message.senderGeneration === state.peer.memberGeneration) {
|
|
153
|
+
state.outcome = { status: 'completed', summary: metadata(reply.message.body), stopped: false };
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
if (receipt && ['failed', 'cancelled', 'expired'].includes(receipt.status)) break;
|
|
158
|
+
const record = await this.runtime.runtimes.read(input.teamId, launched.runtimeId);
|
|
159
|
+
if (!record || ['lost', 'terminated'].includes(record.state)) break;
|
|
160
|
+
await delay(this.options.pollMs ?? 250, undefined, { signal });
|
|
161
|
+
}
|
|
162
|
+
} catch (error) {
|
|
163
|
+
// Never expose command stderr: tmux arguments contain control/lease
|
|
164
|
+
// credentials. The error's own message and code are safe and say why.
|
|
165
|
+
const reason = safeReason(error);
|
|
166
|
+
state.outcome = { status: signal.aborted ? 'cancelled' : 'failed', summary: signal.aborted
|
|
167
|
+
? 'Expert execution interrupted or timed out.' : `Expert could not start: ${reason}. Inspect /team doctor.`, stopped: false };
|
|
168
|
+
} finally {
|
|
169
|
+
if (state.requestId && state.owner && state.outcome.status !== 'completed') {
|
|
170
|
+
await this.runtime.requests.cancel(state.owner, state.requestId).catch(() => {});
|
|
171
|
+
}
|
|
172
|
+
const stopped = state.runtimeId && state.supervisor
|
|
173
|
+
? await state.supervisor.stop(state.runtimeId, 'stop').then(r => r.status === 'terminated', () => false)
|
|
174
|
+
: true;
|
|
175
|
+
state.outcome = { ...state.outcome, stopped };
|
|
176
|
+
if (stopped) {
|
|
177
|
+
if (state.resource && state.peer) await this.runtime.resources.release(state.peer, '.',
|
|
178
|
+
state.resource.token, state.resource.generation).catch(() => {});
|
|
179
|
+
if (state.peer) await this.runtime.requests.leave(state.peer).catch(() => {});
|
|
180
|
+
if (state.runtimeId && state.supervisor) {
|
|
181
|
+
await this.runtime.runtimes.removeTerminated(input.teamId, state.runtimeId, state.supervisor.owner).catch(() => {});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return state.outcome;
|
|
186
|
+
}
|
|
187
|
+
async close(): Promise<void> {
|
|
188
|
+
this.closing.abort();
|
|
189
|
+
await Promise.allSettled([...this.pending]);
|
|
190
|
+
const setup = await this.setup?.catch(() => undefined);
|
|
191
|
+
if (setup) {
|
|
192
|
+
try { await setup.supervisor.close(); }
|
|
193
|
+
finally { await this.runtime.requests.leave(setup.membership).catch(() => {}); }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { Value } from 'typebox/value';
|
|
3
|
+
import { inspectProcess, sameProcess, type ProcessIdentity } from '../process-identity.ts';
|
|
4
|
+
import { DispatchSchema, LIMITS, bounded, metadata, normalizeTag, terminalAssignment, terminalRun,
|
|
5
|
+
type Assignment, type Dispatch, type Expert, type Owner, type Run, type TeamState } from './domain.ts';
|
|
6
|
+
import { DynamicStore, type Project } from './store.ts';
|
|
7
|
+
|
|
8
|
+
export type ExpertExecution = { readonly teamId: string; readonly projectPath: string; readonly owner: Owner; readonly expert: Expert; readonly assignment: Assignment };
|
|
9
|
+
export type ExpertOutcome = { readonly status: 'completed' | 'failed' | 'cancelled'; readonly summary: string; readonly stopped: boolean };
|
|
10
|
+
export interface ExpertRunner {
|
|
11
|
+
run(input: ExpertExecution, signal: AbortSignal): Promise<ExpertOutcome>;
|
|
12
|
+
close(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export type ServiceOptions = {
|
|
15
|
+
readonly sessionId: string;
|
|
16
|
+
readonly identity: ProcessIdentity;
|
|
17
|
+
readonly instanceId?: string;
|
|
18
|
+
readonly now?: () => number;
|
|
19
|
+
readonly ownerAlive?: (owner: Owner) => Promise<boolean>;
|
|
20
|
+
readonly onRun?: (run: Run, state: TeamState) => void;
|
|
21
|
+
readonly onResult?: (assignment: Assignment) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Write-capable Experts each work in their own Git worktree, so they may run
|
|
24
|
+
* in parallel. Without isolation they share one checkout and run one at a time.
|
|
25
|
+
*/
|
|
26
|
+
readonly isolatedWriters?: boolean;
|
|
27
|
+
};
|
|
28
|
+
export async function ownerAlive(owner: Owner): Promise<boolean> {
|
|
29
|
+
const identity = await inspectProcess(owner.processPid);
|
|
30
|
+
if (identity) return sameProcess(owner, identity);
|
|
31
|
+
// Inability to inspect is not proof of death (permissions/platform failure).
|
|
32
|
+
try { process.kill(owner.processPid, 0); return true; }
|
|
33
|
+
catch (error) { return (error as NodeJS.ErrnoException).code !== 'ESRCH'; }
|
|
34
|
+
}
|
|
35
|
+
const owns = (state: TeamState, owner?: Owner): boolean => !!owner && state.owner?.instanceId === owner.instanceId && state.owner.epoch === owner.epoch;
|
|
36
|
+
const timestamp = (now: () => number): string => new Date(now()).toISOString();
|
|
37
|
+
|
|
38
|
+
function prune(state: TeamState): TeamState {
|
|
39
|
+
const removable = state.runs.filter(run => terminalRun(run) && !state.assignments.some(a => a.runId === run.id && a.status === 'cancelled_waiting'));
|
|
40
|
+
const remove = new Set(removable.slice(0, Math.max(0, state.runs.length - LIMITS.runs + 1)).map(run => run.id));
|
|
41
|
+
const assignments = state.assignments.filter(a => !remove.has(a.runId));
|
|
42
|
+
const excess = Math.max(0, assignments.length - LIMITS.assignments + 1);
|
|
43
|
+
const old = new Set(assignments.filter(a => terminalAssignment(a) && a.status !== 'cancelled_waiting').slice(0, excess).map(a => a.id));
|
|
44
|
+
return { ...state, runs: state.runs.filter(run => !remove.has(run.id)), assignments: assignments.filter(a => !old.has(a.id)) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class DynamicTeamService {
|
|
48
|
+
private owner?: Owner;
|
|
49
|
+
private closed = false;
|
|
50
|
+
private readonly executions = new Map<string, { controller: AbortController; promise: Promise<void> }>();
|
|
51
|
+
private readonly now: () => number;
|
|
52
|
+
private readonly instanceId: string;
|
|
53
|
+
constructor(readonly store: DynamicStore, readonly project: Project, readonly runner: ExpertRunner, private readonly options: ServiceOptions) {
|
|
54
|
+
this.now = options.now ?? Date.now;
|
|
55
|
+
this.instanceId = options.instanceId ?? randomUUID();
|
|
56
|
+
}
|
|
57
|
+
get isOwner(): boolean { return !!this.owner && !this.closed; }
|
|
58
|
+
snapshot(): Promise<TeamState | undefined> { return this.store.read(this.project.teamId); }
|
|
59
|
+
private assertOwner(state: TeamState | undefined): asserts state is TeamState {
|
|
60
|
+
if (this.closed || !state || !owns(state, this.owner)) throw new Error('Run owner is fenced; use /team doctor.');
|
|
61
|
+
}
|
|
62
|
+
async submit(objective: string): Promise<Run> {
|
|
63
|
+
if (this.closed) throw new Error('Team session is shutting down.');
|
|
64
|
+
bounded(objective, 8192, 'Objective');
|
|
65
|
+
if (!objective.trim()) throw new Error('An objective is required.');
|
|
66
|
+
const at = timestamp(this.now);
|
|
67
|
+
const run: Run = { id: randomUUID(), objective: metadata(objective, 8192), summary: '', status: 'queued', createdAt: at, updatedAt: at };
|
|
68
|
+
const result = await this.store.update(this.project, async current => {
|
|
69
|
+
const state = prune(current ?? {
|
|
70
|
+
schemaVersion: 1, teamId: this.project.teamId, projectPath: this.project.path, epoch: 0,
|
|
71
|
+
orchestrator: { summary: '' }, experts: [], assignments: [], runs: [], createdAt: at, updatedAt: at,
|
|
72
|
+
});
|
|
73
|
+
if (state.runs.length >= LIMITS.runs) throw new Error('Run queue quota reached. Cancel queued Runs first.');
|
|
74
|
+
const claim = !state.owner || owns(state, this.owner) || !await (this.options.ownerAlive ?? ownerAlive)(state.owner);
|
|
75
|
+
if (!claim) return { state: { ...state, runs: [...state.runs, run], updatedAt: at }, result: undefined };
|
|
76
|
+
const owner: Owner = owns(state, this.owner) ? this.owner! : {
|
|
77
|
+
...this.options.identity, sessionId: this.options.sessionId, instanceId: this.instanceId, epoch: state.epoch + 1,
|
|
78
|
+
};
|
|
79
|
+
const interrupted = !owns(state, this.owner);
|
|
80
|
+
const abandoned = state.assignments.filter(a => a.status === 'running');
|
|
81
|
+
return { state: {
|
|
82
|
+
...state, owner, epoch: owner.epoch, updatedAt: at,
|
|
83
|
+
orchestrator: { ...state.orchestrator, lastSessionId: owner.sessionId },
|
|
84
|
+
runs: [...state.runs.map(r => interrupted && r.status === 'active' ? {
|
|
85
|
+
...r, status: 'interrupted' as const, summary: 'Previous owner ended; no automatic retry.', updatedAt: at, endedAt: at,
|
|
86
|
+
} : r), run],
|
|
87
|
+
assignments: state.assignments.map(a => interrupted && !terminalAssignment(a) ? {
|
|
88
|
+
...a, status: a.status === 'running' ? 'cancelled_waiting' as const : 'cancelled' as const,
|
|
89
|
+
task: '', error: 'Previous owner ended; execution is not retried.', updatedAt: at, endedAt: at,
|
|
90
|
+
} : a),
|
|
91
|
+
experts: state.experts.map(e => interrupted && abandoned.some(a => a.expertId === e.id) ? { ...e, status: 'blocked' as const, updatedAt: at } : e),
|
|
92
|
+
}, result: owner };
|
|
93
|
+
});
|
|
94
|
+
if (result) this.owner = result;
|
|
95
|
+
return run;
|
|
96
|
+
}
|
|
97
|
+
async tick(startRun = true): Promise<void> {
|
|
98
|
+
if (!this.isOwner) return;
|
|
99
|
+
if (startRun) {
|
|
100
|
+
const activated = await this.store.update(this.project, state => {
|
|
101
|
+
this.assertOwner(state);
|
|
102
|
+
if (state.runs.some(r => r.status === 'active')) return { state, result: undefined };
|
|
103
|
+
const next = state.runs.find(r => r.status === 'queued');
|
|
104
|
+
if (!next) return { state, result: undefined };
|
|
105
|
+
const at = timestamp(this.now);
|
|
106
|
+
const run: Run = { ...next, status: 'active', startedAt: at, updatedAt: at };
|
|
107
|
+
const updated = { ...state, runs: state.runs.map(r => r.id === run.id ? run : r), updatedAt: at };
|
|
108
|
+
return { state: updated, result: { run, state: updated } };
|
|
109
|
+
});
|
|
110
|
+
if (activated) {
|
|
111
|
+
try { this.options.onRun?.(activated.run, activated.state); }
|
|
112
|
+
catch { await this.cancelRun(activated.run.id, 'Orchestrator turn could not start; no retry.'); }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
await this.pump();
|
|
116
|
+
}
|
|
117
|
+
async dispatch(input: Dispatch): Promise<{ expertId: string; sessionRef: string; assignmentId: string; decision: 'created' | 'reused' }> {
|
|
118
|
+
if (!Value.Check(DispatchSchema, input)) throw new Error('Invalid dispatch.');
|
|
119
|
+
bounded(input.task, 8192, 'Task'); bounded(input.instructions ?? '', 4096, 'Instructions');
|
|
120
|
+
const role = normalizeTag(input.role);
|
|
121
|
+
const capabilities = [...new Set(input.capabilities.map(normalizeTag))].sort();
|
|
122
|
+
const result = await this.store.update(this.project, current => {
|
|
123
|
+
this.assertOwner(current);
|
|
124
|
+
const state = prune(current);
|
|
125
|
+
const run = state.runs.find(r => r.status === 'active');
|
|
126
|
+
if (!run) throw new Error('No active Run. Start with /team <objective>.');
|
|
127
|
+
if (state.assignments.length >= LIMITS.assignments) throw new Error('Assignment quota reached.');
|
|
128
|
+
// A role is one Expert: the same role is reused (and queues while busy),
|
|
129
|
+
// a different role is a new Expert that can run in parallel. Matching on
|
|
130
|
+
// capabilities put every "implement/test/pr" task on one Expert, in series.
|
|
131
|
+
const existing = state.experts.find(e => e.role === role);
|
|
132
|
+
if (existing && !capabilities.every(c => existing.capabilities.includes(c))) throw new Error('Existing role lacks these capabilities; duplicate-role capacity is disabled.');
|
|
133
|
+
if (existing && (existing.policy.tools.length !== input.policy.tools.length || input.policy.tools.some(t => !existing.policy.tools.includes(t)))) {
|
|
134
|
+
throw new Error('Existing Expert tool policy differs; implicit policy changes are forbidden.');
|
|
135
|
+
}
|
|
136
|
+
if (!existing && state.experts.length >= LIMITS.experts) throw new Error('Expert registry quota reached.');
|
|
137
|
+
const at = timestamp(this.now);
|
|
138
|
+
const expert: Expert = existing ?? {
|
|
139
|
+
id: randomUUID(), role, capabilities, instructions: metadata(input.instructions ?? ''), policy: input.policy,
|
|
140
|
+
sessionRef: randomUUID(), generation: 0, status: 'idle', memory: '', history: [], createdAt: at, updatedAt: at,
|
|
141
|
+
};
|
|
142
|
+
if (expert.status === 'blocked') throw new Error('Expert has unresolved execution; /team doctor. No duplicate will be created.');
|
|
143
|
+
const assignment: Assignment = {
|
|
144
|
+
id: randomUUID(), runId: run.id, expertId: expert.id, generation: 0, ownerEpoch: this.owner!.epoch,
|
|
145
|
+
status: 'queued', task: metadata(input.task, 8192), result: '', error: '', createdAt: at, updatedAt: at,
|
|
146
|
+
};
|
|
147
|
+
const updatedExpert = { ...expert, history: [...expert.history, assignment.id].slice(-LIMITS.history), updatedAt: at };
|
|
148
|
+
return { state: { ...state,
|
|
149
|
+
experts: existing ? state.experts.map(e => e.id === expert.id ? updatedExpert : e) : [...state.experts, updatedExpert],
|
|
150
|
+
assignments: [...state.assignments, assignment], updatedAt: at,
|
|
151
|
+
}, result: { expertId: expert.id, sessionRef: expert.sessionRef, assignmentId: assignment.id, decision: existing ? 'reused' as const : 'created' as const } };
|
|
152
|
+
});
|
|
153
|
+
await this.pump();
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
private async pump(): Promise<void> {
|
|
157
|
+
if (!this.isOwner) return;
|
|
158
|
+
const launches = await this.store.update(this.project, state => {
|
|
159
|
+
this.assertOwner(state);
|
|
160
|
+
const active = state.runs.find(r => r.status === 'active');
|
|
161
|
+
const free = LIMITS.concurrent - state.experts.filter(e => e.status === 'busy' || e.status === 'blocked').length;
|
|
162
|
+
const selected: Assignment[] = [];
|
|
163
|
+
const isolated = this.options.isolatedWriters === true;
|
|
164
|
+
const writerBusy = !isolated && state.experts.some(e => e.status === 'busy' && e.policy.tools.some(tool => ['edit', 'write', 'bash'].includes(tool)));
|
|
165
|
+
for (const a of state.assignments) {
|
|
166
|
+
const expert = state.experts.find(e => e.id === a.expertId);
|
|
167
|
+
const writer = expert?.policy.tools.some(tool => ['edit', 'write', 'bash'].includes(tool)) ?? false;
|
|
168
|
+
if (selected.length >= free) break;
|
|
169
|
+
if (a.runId === active?.id && a.status === 'queued' && expert?.status === 'idle' &&
|
|
170
|
+
!selected.some(s => s.expertId === a.expertId) && !(writer && !isolated && (writerBusy || selected.some(s => {
|
|
171
|
+
const candidate = state.experts.find(e => e.id === s.expertId);
|
|
172
|
+
return candidate?.policy.tools.some(tool => ['edit', 'write', 'bash'].includes(tool));
|
|
173
|
+
})))) selected.push(a);
|
|
174
|
+
}
|
|
175
|
+
const at = timestamp(this.now);
|
|
176
|
+
const experts = state.experts.map(e => selected.some(a => a.expertId === e.id) ? { ...e, generation: e.generation + 1, status: 'busy' as const, updatedAt: at } : e);
|
|
177
|
+
const assignments = state.assignments.map(a => selected.some(s => s.id === a.id) ? {
|
|
178
|
+
...a, generation: experts.find(e => e.id === a.expertId)!.generation, status: 'running' as const, updatedAt: at,
|
|
179
|
+
} : a);
|
|
180
|
+
const executions = assignments.filter(a => selected.some(s => s.id === a.id)).map(assignment => ({
|
|
181
|
+
teamId: state.teamId, projectPath: state.projectPath, owner: this.owner!, expert: experts.find(e => e.id === assignment.expertId)!, assignment,
|
|
182
|
+
}));
|
|
183
|
+
return { state: { ...state, experts, assignments, updatedAt: at }, result: executions };
|
|
184
|
+
});
|
|
185
|
+
for (const execution of launches) {
|
|
186
|
+
if (!this.isOwner) break;
|
|
187
|
+
const controller = new AbortController();
|
|
188
|
+
// The durable running transition precedes model exposure. Even a crash here never retries it.
|
|
189
|
+
const promise = Promise.resolve().then(() => this.runner.run(execution, controller.signal))
|
|
190
|
+
.catch((): ExpertOutcome => ({ status: 'failed', summary: 'Worker execution failed; inspect /team doctor.', stopped: false }))
|
|
191
|
+
.then(outcome => this.settle(execution.assignment, outcome))
|
|
192
|
+
.catch(() => {})
|
|
193
|
+
.finally(() => { this.executions.delete(execution.assignment.id); });
|
|
194
|
+
this.executions.set(execution.assignment.id, { controller, promise });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
private async settle(original: Assignment, outcome: ExpertOutcome): Promise<void> {
|
|
198
|
+
const settled = await this.store.update(this.project, state => {
|
|
199
|
+
if (!state || !owns(state, this.owner)) return { state: state!, result: undefined };
|
|
200
|
+
const assignment = state.assignments.find(a => a.id === original.id);
|
|
201
|
+
const expert = state.experts.find(e => e.id === original.expertId);
|
|
202
|
+
if (!assignment || !expert || assignment.generation !== original.generation || expert.generation !== original.generation || assignment.ownerEpoch !== this.owner?.epoch) return { state, result: undefined };
|
|
203
|
+
const at = timestamp(this.now);
|
|
204
|
+
if (terminalAssignment(assignment)) {
|
|
205
|
+
// A proved stop can release capacity, but a late result never changes the cancelled outcome.
|
|
206
|
+
if (assignment.status !== 'cancelled_waiting' || !outcome.stopped) return { state, result: undefined };
|
|
207
|
+
return { state: { ...state,
|
|
208
|
+
assignments: state.assignments.map(a => a.id === assignment.id ? { ...a, status: 'cancelled' as const, updatedAt: at } : a),
|
|
209
|
+
experts: state.experts.map(e => e.id === expert.id ? { ...e, status: 'idle' as const, updatedAt: at } : e),
|
|
210
|
+
}, result: undefined };
|
|
211
|
+
}
|
|
212
|
+
if (state.runs.find(r => r.id === assignment.runId)?.status !== 'active') return { state, result: undefined };
|
|
213
|
+
const success = outcome.status === 'completed' && outcome.stopped;
|
|
214
|
+
const next: Assignment = { ...assignment, task: '', status: success ? 'completed' : 'failed',
|
|
215
|
+
result: success ? metadata(outcome.summary) : '', error: success ? '' : metadata(outcome.summary, 512), endedAt: at, updatedAt: at };
|
|
216
|
+
return { state: { ...state, updatedAt: at,
|
|
217
|
+
assignments: state.assignments.map(a => a.id === next.id ? next : a),
|
|
218
|
+
experts: state.experts.map(e => e.id === expert.id ? { ...e, status: outcome.stopped ? 'idle' as const : 'blocked' as const,
|
|
219
|
+
memory: success ? next.result : e.memory, updatedAt: at } : e),
|
|
220
|
+
}, result: next };
|
|
221
|
+
});
|
|
222
|
+
if (settled && !this.closed) this.options.onResult?.(settled);
|
|
223
|
+
if (!this.closed) await this.pump();
|
|
224
|
+
}
|
|
225
|
+
async finish(summary: string): Promise<void> {
|
|
226
|
+
bounded(summary, 4096, 'Summary');
|
|
227
|
+
await this.store.update(this.project, state => {
|
|
228
|
+
this.assertOwner(state);
|
|
229
|
+
const run = state.runs.find(r => r.status === 'active');
|
|
230
|
+
if (!run) throw new Error('No active Run.');
|
|
231
|
+
if (state.assignments.some(a => a.runId === run.id && (!terminalAssignment(a) || a.status === 'cancelled_waiting'))) throw new Error('Assignments remain outstanding; finish is blocked.');
|
|
232
|
+
const at = timestamp(this.now);
|
|
233
|
+
return { state: { ...state, updatedAt: at, orchestrator: { ...state.orchestrator, summary: metadata(summary) },
|
|
234
|
+
runs: state.runs.map(r => r.id === run.id ? { ...r, status: 'completed' as const, summary: metadata(summary), updatedAt: at, endedAt: at } : r),
|
|
235
|
+
}, result: undefined };
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
async cancelAssignment(id: string, reason = 'Cancelled by orchestrator.'): Promise<void> {
|
|
239
|
+
await this.store.update(this.project, state => {
|
|
240
|
+
this.assertOwner(state);
|
|
241
|
+
const a = state.assignments.find(a => a.id === id);
|
|
242
|
+
if (!a) throw new Error('Unknown assignment.');
|
|
243
|
+
if (terminalAssignment(a)) return { state, result: undefined };
|
|
244
|
+
const at = timestamp(this.now);
|
|
245
|
+
return { state: { ...state, updatedAt: at,
|
|
246
|
+
assignments: state.assignments.map(item => item.id === id ? { ...item, status: a.status === 'running' ? 'cancelled_waiting' as const : 'cancelled' as const,
|
|
247
|
+
task: '', error: metadata(reason, 512), updatedAt: at, endedAt: at } : item),
|
|
248
|
+
experts: state.experts.map(e => e.id === a.expertId && a.status === 'running' ? { ...e, status: 'blocked' as const, updatedAt: at } : e),
|
|
249
|
+
}, result: undefined };
|
|
250
|
+
});
|
|
251
|
+
this.executions.get(id)?.controller.abort();
|
|
252
|
+
}
|
|
253
|
+
async cancelRun(id?: string, reason = 'Cancelled by user.'): Promise<void> {
|
|
254
|
+
const cancelled = await this.store.update(this.project, state => {
|
|
255
|
+
this.assertOwner(state);
|
|
256
|
+
const run = id ? state.runs.find(r => r.id === id) : state.runs.find(r => r.status === 'active');
|
|
257
|
+
if (!run) throw new Error('Unknown Run.');
|
|
258
|
+
if (terminalRun(run)) return { state, result: [] as string[] };
|
|
259
|
+
const at = timestamp(this.now);
|
|
260
|
+
const targets = state.assignments.filter(a => a.runId === run.id && !terminalAssignment(a));
|
|
261
|
+
return { state: { ...state, updatedAt: at,
|
|
262
|
+
runs: state.runs.map(r => r.id === run.id ? { ...r, status: 'cancelled' as const, summary: metadata(reason), updatedAt: at, endedAt: at } : r),
|
|
263
|
+
assignments: state.assignments.map(a => targets.some(t => t.id === a.id) ? { ...a, task: '',
|
|
264
|
+
status: a.status === 'running' ? 'cancelled_waiting' as const : 'cancelled' as const,
|
|
265
|
+
error: metadata(reason, 512), updatedAt: at, endedAt: at } : a),
|
|
266
|
+
experts: state.experts.map(e => targets.some(a => a.expertId === e.id && a.status === 'running') ? { ...e, status: 'blocked' as const, updatedAt: at } : e),
|
|
267
|
+
}, result: targets.map(a => a.id) };
|
|
268
|
+
});
|
|
269
|
+
for (const assignmentId of cancelled) this.executions.get(assignmentId)?.controller.abort();
|
|
270
|
+
}
|
|
271
|
+
async close(reason: string): Promise<void> {
|
|
272
|
+
if (this.closed) return;
|
|
273
|
+
const state = await this.snapshot();
|
|
274
|
+
if (state && owns(state, this.owner)) {
|
|
275
|
+
const active = state.runs.find(r => r.status === 'active');
|
|
276
|
+
if (active) await this.cancelRun(active.id, `Session ${reason}; safe interruption, no automatic retry.`);
|
|
277
|
+
}
|
|
278
|
+
this.closed = true;
|
|
279
|
+
for (const execution of this.executions.values()) execution.controller.abort();
|
|
280
|
+
try {
|
|
281
|
+
await this.runner.close();
|
|
282
|
+
await Promise.all([...this.executions.values()].map(execution => execution.promise));
|
|
283
|
+
} finally {
|
|
284
|
+
if (this.owner) await this.store.update(this.project, current => {
|
|
285
|
+
if (!current || !owns(current, this.owner)) return { state: current!, result: undefined };
|
|
286
|
+
const { owner: _owner, ...retained } = current;
|
|
287
|
+
return { state: { ...retained, epoch: current.epoch + 1, updatedAt: timestamp(this.now) }, result: undefined };
|
|
288
|
+
});
|
|
289
|
+
this.owner = undefined;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { ensurePrivateDirectory, ensurePrivateTree, readJson, replaceAtomicJson, withStorageLock } from '../storage/atomic.ts';
|
|
5
|
+
import { defaultStorageRoot } from '../storage/paths.ts';
|
|
6
|
+
import { assertState, LIMITS, type TeamState } from './domain.ts';
|
|
7
|
+
|
|
8
|
+
export type Project = { readonly teamId: string; readonly path: string };
|
|
9
|
+
export async function resolveProject(cwd: string): Promise<Project> {
|
|
10
|
+
const canonical = await realpath(cwd);
|
|
11
|
+
const repository = async (path: string): Promise<string | undefined> => {
|
|
12
|
+
const marker = await lstat(join(path, '.git')).catch(error => {
|
|
13
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
14
|
+
throw error;
|
|
15
|
+
});
|
|
16
|
+
if (marker && !marker.isSymbolicLink() && (marker.isDirectory() || marker.isFile())) return path;
|
|
17
|
+
return dirname(path) === path ? undefined : repository(dirname(path));
|
|
18
|
+
};
|
|
19
|
+
const path = await repository(canonical) ?? canonical;
|
|
20
|
+
return { teamId: `p-${createHash('sha256').update(path).digest('hex').slice(0, 40)}`, path };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class DynamicStore {
|
|
24
|
+
readonly root: string;
|
|
25
|
+
constructor(root = join(defaultStorageRoot(), 'orchestration-v2')) { this.root = resolve(root); }
|
|
26
|
+
directory(teamId: string): string {
|
|
27
|
+
if (!/^p-[a-f0-9]{40}$/.test(teamId)) throw new Error('Invalid project Team identity.');
|
|
28
|
+
return join(this.root, 'projects', teamId);
|
|
29
|
+
}
|
|
30
|
+
sessionPath(teamId: string, sessionRef: string): string {
|
|
31
|
+
if (!/^[a-zA-Z0-9-]{1,128}$/.test(sessionRef)) throw new Error('Invalid Expert session reference.');
|
|
32
|
+
return join(this.directory(teamId), 'sessions', `${sessionRef}.jsonl`);
|
|
33
|
+
}
|
|
34
|
+
async read(teamId: string): Promise<TeamState | undefined> {
|
|
35
|
+
const directory = this.directory(teamId);
|
|
36
|
+
for (const path of [this.root, join(this.root, 'projects'), directory]) {
|
|
37
|
+
const present = await ensurePrivateDirectory(path, false).then(() => true, error => {
|
|
38
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
|
39
|
+
throw error;
|
|
40
|
+
});
|
|
41
|
+
if (!present) return undefined;
|
|
42
|
+
}
|
|
43
|
+
return readJson(join(directory, 'state.json'), assertState, LIMITS.recordBytes);
|
|
44
|
+
}
|
|
45
|
+
async update<T>(project: Project, action: (current: TeamState | undefined) => Promise<{ state: TeamState; result: T }> | { state: TeamState; result: T }): Promise<T> {
|
|
46
|
+
const directory = this.directory(project.teamId);
|
|
47
|
+
await ensurePrivateTree(this.root, 'projects', project.teamId);
|
|
48
|
+
return withStorageLock(join(directory, 'state.lock'), async () => {
|
|
49
|
+
const current = await this.read(project.teamId);
|
|
50
|
+
if (current && current.projectPath !== project.path) throw new Error('Project binding mismatch.');
|
|
51
|
+
const { state, result } = await action(current);
|
|
52
|
+
assertState(state);
|
|
53
|
+
await replaceAtomicJson(join(directory, 'state.json'), state, { maxBytes: LIMITS.recordBytes });
|
|
54
|
+
return result;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { metadata, LIMITS, type TeamState } from './domain.ts';
|
|
2
|
+
|
|
3
|
+
/** Explicit projection only: never serialize owner, lease, control, or process tokens. */
|
|
4
|
+
export function teamView(state: TeamState | undefined, mode: 'status' | 'history' | 'doctor' = 'status'): string {
|
|
5
|
+
if (!state) return 'No project Team yet. Start with /team <objective>.';
|
|
6
|
+
const runs = mode === 'history' ? state.runs.slice(-20) : state.runs.filter(r => ['active', 'queued'].includes(r.status)).slice(0, 20);
|
|
7
|
+
const lines = [
|
|
8
|
+
`Team ${state.teamId}`,
|
|
9
|
+
`Runs: ${state.runs.length} | Experts: ${state.experts.length} | Parallel limit: ${LIMITS.concurrent}`,
|
|
10
|
+
...runs.map(r => `Run ${r.id} [${r.status}] ${metadata(r.objective, 160).replace(/\s+/g, ' ')}`),
|
|
11
|
+
...state.experts.map(e => `Expert ${e.id} ${e.role} [${e.status}] generation ${e.generation} | history ${e.history.length}`),
|
|
12
|
+
...state.assignments.slice(-20).map(a => `Assignment ${a.id} [${a.status}] expert ${a.expertId}`),
|
|
13
|
+
];
|
|
14
|
+
if (mode === 'doctor') lines.push(
|
|
15
|
+
`Owner: ${state.owner ? 'recorded (not a liveness guarantee)' : 'none'} | epoch ${state.epoch}`,
|
|
16
|
+
`Blocked experts: ${state.experts.filter(e => e.status === 'blocked').length}`,
|
|
17
|
+
'Production execution requires authenticated Pi and tmux. No automatic retry or worker adoption.',
|
|
18
|
+
'Blocked execution requires manual process-identity verification; never signal by name or unverified PID.',
|
|
19
|
+
);
|
|
20
|
+
return metadata(lines.join('\n'), 16384);
|
|
21
|
+
}
|