@ours.network/fleet 2.2.5 → 2.3.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.
Files changed (38) hide show
  1. package/dist/application/task-room-service.d.ts +11 -0
  2. package/dist/application/task-room-service.js +37 -14
  3. package/dist/atomic-file.d.ts +2 -0
  4. package/dist/atomic-file.js +2 -1
  5. package/dist/build-info.json +4 -4
  6. package/dist/config.d.ts +6 -0
  7. package/dist/config.js +6 -3
  8. package/dist/docs.d.ts +1 -1
  9. package/dist/docs.js +37 -0
  10. package/dist/owner-channel/commands.js +25 -12
  11. package/dist/reports/tasks.js +4 -3
  12. package/dist/rooms-tasks/add-member.js +17 -6
  13. package/dist/rooms-tasks/cli.js +79 -5
  14. package/dist/rooms-tasks/config.js +11 -0
  15. package/dist/rooms-tasks/layout-member-retirement.d.ts +15 -0
  16. package/dist/rooms-tasks/layout-member-retirement.js +26 -1
  17. package/dist/rooms-tasks/layout-service.d.ts +2 -1
  18. package/dist/rooms-tasks/layout-service.js +10 -4
  19. package/dist/rooms-tasks/layout.d.ts +4 -2
  20. package/dist/rooms-tasks/layout.js +21 -4
  21. package/dist/rooms-tasks/member-naming.d.ts +43 -0
  22. package/dist/rooms-tasks/member-naming.js +186 -0
  23. package/dist/rooms-tasks/member-overrides.js +1 -0
  24. package/dist/rooms-tasks/mission-keys.d.ts +83 -0
  25. package/dist/rooms-tasks/mission-keys.js +250 -0
  26. package/dist/rooms-tasks/provision.d.ts +4 -0
  27. package/dist/rooms-tasks/provision.js +37 -3
  28. package/dist/rooms-tasks/task-lists.d.ts +18 -2
  29. package/dist/rooms-tasks/task-lists.js +60 -5
  30. package/dist/rooms-tasks/task-room-name.d.ts +7 -2
  31. package/dist/rooms-tasks/task-room-name.js +13 -4
  32. package/dist/rooms-tasks/task-state.d.ts +40 -0
  33. package/dist/rooms-tasks/task-state.js +288 -45
  34. package/dist/rooms-tasks/types.d.ts +35 -1
  35. package/dist/rooms-tasks/types.js +1 -1
  36. package/dist/web/runtime.js +4 -1
  37. package/dist/web/server.js +62 -27
  38. package/package.json +5 -5
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Role-scoped agent names for task members.
3
+ *
4
+ * <KEY>-<Stem> first member of a stem (no `-1`)
5
+ * <KEY>-<Stem>-<n> later members, n >= 2
6
+ *
7
+ * The stem comes from the explicitly selected named Role when one exists,
8
+ * otherwise from a stable room label, participant key or slot. It is
9
+ * PascalCase Latin alphanumerics, truncated with ordinal headroom so the full
10
+ * name fits the 64-character Fleet role-name budget. The counter bucket is the
11
+ * case-folded truncated stem, chosen before numbering, so labels that collapse
12
+ * to the same stem share one counter and can never produce the same name.
13
+ *
14
+ * Allocations are durable on the task record and keyed by a stable allocation
15
+ * key: provisioning, recovery and restart reuse them; additions and
16
+ * replacements use new keys and consume the next ordinal. Names that existed
17
+ * before mission keys are frozen exactly as launched.
18
+ */
19
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { stateRoot } from '../paths.js';
22
+ export const AGENT_NAME_MAX = 64;
23
+ /** Ordinal suffix headroom: `-999`. */
24
+ export const MAX_ROLE_ORDINAL = 999;
25
+ const SUFFIX_HEADROOM = 1 + String(MAX_ROLE_ORDINAL).length;
26
+ export class MemberNameError extends Error {
27
+ code;
28
+ constructor(code, message) {
29
+ super(message);
30
+ this.code = code;
31
+ this.name = 'MemberNameError';
32
+ }
33
+ }
34
+ /** PascalCase join of Latin alphanumeric words; diacritics folded, other scripts dropped. */
35
+ export function labelStem(label) {
36
+ const words = label.normalize('NFKD').replace(/\p{M}/gu, '').split(/[^A-Za-z0-9]+/).filter(Boolean);
37
+ const joined = words.map(word => word[0].toUpperCase() + word.slice(1)).join('');
38
+ // Fleet role names must start with an alphanumeric; a leading digit is allowed.
39
+ return joined;
40
+ }
41
+ /** First candidate that survives normalization, else `Agent`. */
42
+ export function chooseStem(key, candidates) {
43
+ const budget = AGENT_NAME_MAX - key.length - 1 - SUFFIX_HEADROOM;
44
+ if (budget < 1)
45
+ throw new MemberNameError('invalid_label', `mission key ${key} leaves no room for a role name`);
46
+ for (const candidate of candidates) {
47
+ if (!candidate.label)
48
+ continue;
49
+ const stem = labelStem(candidate.label).slice(0, budget);
50
+ if (stem)
51
+ return { stem, source: candidate.source };
52
+ }
53
+ return { stem: 'Agent'.slice(0, budget), source: 'fallback' };
54
+ }
55
+ export const bucketOf = (stem) => stem.toLowerCase();
56
+ /** Own-property read: buckets such as `constructor` must never hit Object.prototype. */
57
+ export const ownEntry = (record, key) => record && Object.hasOwn(record, key) ? record[key] : undefined;
58
+ export function formatMemberName(key, stem, ordinal) {
59
+ return ordinal === 1 ? `${key}-${stem}` : `${key}-${stem}-${ordinal}`;
60
+ }
61
+ /**
62
+ * Pure allocation step: returns the existing allocation for `allocationKey`,
63
+ * or a new one plus the updated naming. The caller persists under the task
64
+ * lock and checks external name conflicts before launch.
65
+ */
66
+ export function allocateName(naming, key, allocationKey, candidates) {
67
+ const current = naming
68
+ ? { version: 2, counters: { ...naming.counters }, allocations: { ...naming.allocations } }
69
+ : { version: 2, counters: {}, allocations: {} };
70
+ const existing = ownEntry(current.allocations, allocationKey);
71
+ if (existing)
72
+ return { naming: naming ?? current, allocation: existing, created: false };
73
+ const { stem, source } = chooseStem(key, candidates);
74
+ const bucket = bucketOf(stem);
75
+ const ordinal = (ownEntry(current.counters, bucket) ?? 0) + 1;
76
+ if (ordinal > MAX_ROLE_ORDINAL)
77
+ throw new MemberNameError('role_ordinal_exhausted', `mission ${key} has used every ${stem} ordinal`);
78
+ const allocation = {
79
+ name: formatMemberName(key, stem, ordinal), bucket, ordinal, label_source: source,
80
+ };
81
+ current.counters[bucket] = ordinal;
82
+ current.allocations[allocationKey] = allocation;
83
+ return { naming: current, allocation, created: true };
84
+ }
85
+ // ── Frozen historical names (one-time migration) ─────────────────────────
86
+ const LEGACY_TEMPLATE_NAME = /^[0-9a-z]{8}-([A-Za-z][A-Za-z0-9_-]*)-([1-9][0-9]*)$/;
87
+ function readJson(path) {
88
+ try {
89
+ return JSON.parse(readFileSync(path, 'utf8'));
90
+ }
91
+ catch (error) {
92
+ if (error.code === 'ENOENT')
93
+ return undefined;
94
+ throw error;
95
+ }
96
+ }
97
+ /** Every physical agent a historical task already launched or planned, from all durable sources. */
98
+ function legacyMembers(task) {
99
+ const id8 = task.task_id.slice(0, 8);
100
+ const members = [];
101
+ const snapshot = (task.execution_plan?.snapshot.members ?? []);
102
+ const bySlot = new Map(snapshot.map(member => [member.slot, member]));
103
+ const candidatesFor = (slot, role) => {
104
+ const sealed = bySlot.get(slot);
105
+ return [
106
+ { label: sealed?.role_preset?.id, source: 'role_override' },
107
+ { label: sealed?.role_label, source: 'template_ref' },
108
+ { label: role ?? sealed?.role, source: 'room_role' },
109
+ { label: slot, source: 'slot' },
110
+ ];
111
+ };
112
+ for (const member of snapshot)
113
+ for (let i = 1; i <= member.count; i++)
114
+ members.push({ allocationKey: `initial:${member.slot}:${i}`, name: `${id8}-${member.slot}-${i}`,
115
+ candidates: candidatesFor(member.slot, member.role) });
116
+ const additions = join(stateRoot(), 'member-additions', task.task_id);
117
+ if (existsSync(additions))
118
+ for (const file of readdirSync(additions).filter(name => name.endsWith('.json')).sort()) {
119
+ const receipt = readJson(join(additions, file));
120
+ if (!receipt?.roleId || !receipt.requestId)
121
+ continue;
122
+ const slot = LEGACY_TEMPLATE_NAME.exec(receipt.roleId)?.[1] ?? receipt.roleId;
123
+ members.push({ allocationKey: `add:${receipt.requestId}`, name: receipt.roleId, candidates: candidatesFor(slot) });
124
+ }
125
+ if (task.room_id) {
126
+ const room = readJson(join(stateRoot(), 'rooms', `${task.room_id}.json`));
127
+ for (const seat of room?.member_seats ?? []) {
128
+ const match = LEGACY_TEMPLATE_NAME.exec(seat.role_name);
129
+ const slot = seat.slot ?? match?.[1] ?? seat.role_name;
130
+ members.push({ allocationKey: match ? `initial:${match[1]}:${match[2]}` : `seat:${seat.role_name}`,
131
+ name: seat.role_name, candidates: candidatesFor(slot, seat.cowork_role) });
132
+ }
133
+ }
134
+ if (task.layout) {
135
+ const run = readJson(join(stateRoot(), 'layouts', `${task.layout.run_id}.json`));
136
+ for (const [instanceKey, participant] of Object.entries(run?.participants ?? {})) {
137
+ const agent = participant.instance?.agent;
138
+ if (!agent || participant.owned === false)
139
+ continue;
140
+ const key = participant.participant ?? instanceKey;
141
+ const roles = Object.entries(run?.definition?.rooms ?? {})
142
+ .filter(([room, spec]) => spec.members.includes(key) && (!participant.room || participant.room === room))
143
+ .map(([, spec]) => spec.roles?.[key] ?? key);
144
+ const added = run?.additional_members?.[key]?.role;
145
+ const agreed = added ?? (new Set(roles).size === 1 ? roles[0] : undefined);
146
+ members.push({ allocationKey: `layout:${task.layout.run_id}:${instanceKey}`, name: agent, candidates: [
147
+ { label: agreed, source: 'room_role' }, { label: key, source: 'participant_key' }
148
+ ] });
149
+ }
150
+ }
151
+ return members;
152
+ }
153
+ /**
154
+ * Freeze every historical agent name and seed counters by the same label
155
+ * precedence as new names. Deduplicated by physical agent name; counters merge
156
+ * monotonically and existing allocations are never rewritten. Returns the
157
+ * input object unchanged when there is nothing to add.
158
+ */
159
+ export function freezeLegacyMemberNaming(task) {
160
+ const legacy = legacyMembers(task);
161
+ if (!legacy.length)
162
+ return task.member_naming;
163
+ const naming = task.member_naming
164
+ ? { version: 2, counters: { ...task.member_naming.counters }, allocations: { ...task.member_naming.allocations } }
165
+ : { version: 2, counters: {}, allocations: {} };
166
+ const known = new Set(Object.values(naming.allocations).map(allocation => allocation.name));
167
+ const seeded = {};
168
+ let changed = !task.member_naming;
169
+ for (const member of legacy) {
170
+ if (known.has(member.name))
171
+ continue;
172
+ if (ownEntry(naming.allocations, member.allocationKey))
173
+ continue;
174
+ known.add(member.name);
175
+ const { stem } = chooseStem(task.key, member.candidates);
176
+ const bucket = bucketOf(stem);
177
+ seeded[bucket] = (ownEntry(seeded, bucket) ?? 0) + 1;
178
+ naming.allocations[member.allocationKey] = { name: member.name, bucket, frozen: true };
179
+ changed = true;
180
+ }
181
+ for (const [bucket, count] of Object.entries(seeded)) {
182
+ const frozenInBucket = Object.values(naming.allocations).filter(a => a.frozen && a.bucket === bucket).length;
183
+ naming.counters[bucket] = Math.max(ownEntry(naming.counters, bucket) ?? 0, frozenInBucket, count);
184
+ }
185
+ return changed ? naming : task.member_naming;
186
+ }
@@ -190,6 +190,7 @@ export function prepareExecutionPlan(template, cfg, overrides = {}) {
190
190
  loop_source: input.loops !== undefined ? 'cli' : base.loops !== undefined ? 'agent-template' : 'omitted',
191
191
  agent_template_hash: hash,
192
192
  ...(input.role ? { role_preset: { id: input.role, hash: createHash('sha256').update(canonicalJson(rolePreset)).digest('hex') } } : {}),
193
+ ...(cfg.agentTemplateRoleRefs?.[source] ? { role_label: cfg.agentTemplateRoleRefs[source] } : {}),
193
194
  ...(input.brain ? { brain_preset: { id: input.brain, hash: createHash('sha256').update(canonicalJson(brainPreset)).digest('hex') } } : {}),
194
195
  agent_projection: redactLaunchDefinition(definition) };
195
196
  });
@@ -0,0 +1,83 @@
1
+ export declare const DEFAULT_MISSION_PREFIX = "MSN";
2
+ /** Never accepted for a custom list; ROOM/LAYOUT case-fold onto legacy agent names. */
3
+ export declare const RESERVED_MISSION_PREFIXES: readonly string[];
4
+ export declare const MISSION_PREFIX_MAX = 6;
5
+ /** Accepted reference form; canonical task IDs never contain '-'. */
6
+ export declare const MISSION_KEY_RE: RegExp;
7
+ export type MissionKeyErrorCode = 'invalid_prefix' | 'duplicate_prefix' | 'prefix_reserved' | 'default_prefix_frozen' | 'task_deleted' | 'idempotency_conflict' | 'key_space_exhausted' | 'invalid_state';
8
+ export declare class MissionKeyError extends Error {
9
+ readonly code: MissionKeyErrorCode;
10
+ constructor(code: MissionKeyErrorCode, message: string);
11
+ }
12
+ export interface PrefixRecord {
13
+ list_id: string;
14
+ last_issued: number;
15
+ /** active: the list's current prefix; retired: an earlier prefix of the same list; tombstoned: its list was deleted. */
16
+ state: 'active' | 'retired' | 'tombstoned';
17
+ }
18
+ export interface MissionKeyRegistry {
19
+ version: 1;
20
+ default_prefix: string;
21
+ prefixes: Record<string, PrefixRecord>;
22
+ /** Set only after every historical row, receipt and ledger entry is durable. */
23
+ keys_migrated?: true;
24
+ }
25
+ export interface CreationReceipt {
26
+ version: 1;
27
+ task_id: string;
28
+ /** Absent only on a tombstone seeded for a row that never had a key. */
29
+ mission_key?: string;
30
+ list_id: string;
31
+ /** sha256 of the canonical original creation input; absent on receipts seeded for legacy rows. */
32
+ fingerprint?: string;
33
+ /** Complete original creation input, so a resumed create never uses retry input. */
34
+ input?: Record<string, unknown>;
35
+ creation_plan?: string;
36
+ state: 'reserved' | 'created' | 'deleted';
37
+ }
38
+ export interface KeyLedgerEntry {
39
+ version: 1;
40
+ key: string;
41
+ task_id: string;
42
+ state: 'live' | 'deleted';
43
+ }
44
+ /** Innermost cross-process lock for registry, receipts and ledger. Re-entrant within a process. */
45
+ export declare function withMissionKeysLock<T>(fn: () => T): T;
46
+ /** Trim and uppercase ASCII letters; every other deviation is an explicit error. */
47
+ export declare function normalizeMissionPrefix(input: unknown): string;
48
+ /**
49
+ * Deterministic convenience suggestion; never authoritative and never a
50
+ * tombstoned, retired, reserved or active prefix.
51
+ */
52
+ export declare function suggestMissionPrefix(name: string, taken: ReadonlySet<string>): string;
53
+ export declare function readMissionKeyRegistry(): MissionKeyRegistry | undefined;
54
+ export declare function writeMissionKeyRegistry(registry: MissionKeyRegistry): void;
55
+ /**
56
+ * Create the registry on first use. A configured default prefix may change the
57
+ * default list's prefix only until it has issued its first key.
58
+ */
59
+ export declare function ensureMissionKeyRegistry(configuredDefault?: string): MissionKeyRegistry;
60
+ /** Every prefix a custom list may not take: reserved names, the default, and any ever-issued prefix. */
61
+ export declare function unavailablePrefixes(registry: MissionKeyRegistry): Set<string>;
62
+ export declare function activePrefixFor(registry: MissionKeyRegistry, listId: string): string | undefined;
63
+ /**
64
+ * Claim `prefix` as the active prefix of `listId`. `explicit` is false for a
65
+ * server suggestion, which never adopts a tombstone. Caller holds the lock and
66
+ * writes the registry.
67
+ */
68
+ export declare function claimPrefix(registry: MissionKeyRegistry, listId: string, prefix: string, explicit: boolean): void;
69
+ /** Deleting a list keeps its prefixes and high-water marks as tombstones. */
70
+ export declare function tombstoneListPrefixes(registry: MissionKeyRegistry, listId: string): void;
71
+ /** Raise a prefix's high-water mark; the caller persists the registry before publishing the key. */
72
+ export declare function issueNextKey(registry: MissionKeyRegistry, listId: string): string;
73
+ /** Monotonically account for an already-issued key. */
74
+ export declare function observeIssuedKey(registry: MissionKeyRegistry, key: string, listId: string): void;
75
+ export declare const readCreationReceipt: (idempotencyKey: string) => CreationReceipt | undefined;
76
+ export declare function writeCreationReceipt(idempotencyKey: string, receipt: CreationReceipt): void;
77
+ export declare function readKeyLedger(key: string): KeyLedgerEntry | undefined;
78
+ /** Create or confirm the ledger entry; a key never moves to another task. */
79
+ export declare function ensureKeyLedger(key: string, taskId: string, state?: KeyLedgerEntry['state']): void;
80
+ export declare function canonicalFingerprint(input: Record<string, unknown>): string;
81
+ /** Parse a user reference; returns the normalized key, or undefined for non-key input. */
82
+ export declare function parseMissionKeyRef(ref: string): string | undefined;
83
+ export declare const missionKeysExist: () => boolean;
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Human mission keys (`MSN-1`) and the durable state that issues them.
3
+ *
4
+ * The canonical task ID stays the storage, lock and authorization identity.
5
+ * A key is an immutable alias issued once from a per-prefix high-water mark;
6
+ * numbers are never derived from current rows and never reissued.
7
+ *
8
+ * Durable files under `<state>/mission-keys/`, all written by atomic replace
9
+ * while holding the synchronous mission-keys lock (innermost lock: callers may
10
+ * already hold `task-lists.lock` and/or one task lock, never the reverse):
11
+ * registry.json prefixes, owners, high-water marks
12
+ * receipts/<sha256>.json create idempotency receipts, never removed
13
+ * keys/<KEY>.json key ledger: key → canonical task ID, never removed
14
+ *
15
+ * Single ordering invariant for every issued key:
16
+ * high-water mark → receipt → ledger → keyed task row.
17
+ */
18
+ import { createHash } from 'node:crypto';
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { replaceFileAtomically, withSynchronousFileLock } from '../atomic-file.js';
22
+ import { stateRoot } from '../paths.js';
23
+ export const DEFAULT_MISSION_PREFIX = 'MSN';
24
+ /** Never accepted for a custom list; ROOM/LAYOUT case-fold onto legacy agent names. */
25
+ export const RESERVED_MISSION_PREFIXES = ['MSN', 'ROOM', 'LAYOUT', 'TASK'];
26
+ export const MISSION_PREFIX_MAX = 6;
27
+ const PREFIX_RE = /^[A-Z][A-Z0-9]{0,5}$/;
28
+ /** Accepted reference form; canonical task IDs never contain '-'. */
29
+ export const MISSION_KEY_RE = /^([A-Za-z][A-Za-z0-9]{0,5})-([1-9][0-9]{0,8})$/;
30
+ const MAX_MISSION_NUMBER = 999_999_999;
31
+ export class MissionKeyError extends Error {
32
+ code;
33
+ constructor(code, message) {
34
+ super(message);
35
+ this.code = code;
36
+ this.name = 'MissionKeyError';
37
+ }
38
+ }
39
+ const root = () => join(stateRoot(), 'mission-keys');
40
+ const registryPath = () => join(root(), 'registry.json');
41
+ const lockPath = () => join(root(), 'lock');
42
+ const receiptPath = (idempotencyKey) => join(root(), 'receipts', `${createHash('sha256').update(idempotencyKey).digest('hex')}.json`);
43
+ const ledgerPath = (key) => join(root(), 'keys', `${key}.json`);
44
+ let lockDepth = 0;
45
+ /** Innermost cross-process lock for registry, receipts and ledger. Re-entrant within a process. */
46
+ export function withMissionKeysLock(fn) {
47
+ if (lockDepth > 0)
48
+ return fn();
49
+ return withSynchronousFileLock(lockPath(), () => {
50
+ lockDepth++;
51
+ try {
52
+ return fn();
53
+ }
54
+ finally {
55
+ lockDepth--;
56
+ }
57
+ });
58
+ }
59
+ const assertLocked = () => {
60
+ if (lockDepth === 0)
61
+ throw new MissionKeyError('invalid_state', 'mission-keys lock is not held');
62
+ };
63
+ function readJson(path) {
64
+ try {
65
+ return JSON.parse(readFileSync(path, 'utf8'));
66
+ }
67
+ catch (error) {
68
+ if (error.code === 'ENOENT')
69
+ return undefined;
70
+ throw error;
71
+ }
72
+ }
73
+ const writeJson = (path, value) => replaceFileAtomically(path, JSON.stringify(value, null, 2) + '\n', 0o600, {}, { durable: true });
74
+ // ── Prefix rules ─────────────────────────────────────────────────────────
75
+ /** Trim and uppercase ASCII letters; every other deviation is an explicit error. */
76
+ export function normalizeMissionPrefix(input) {
77
+ if (typeof input !== 'string')
78
+ throw new MissionKeyError('invalid_prefix', 'prefix must be a string');
79
+ const value = input.trim().replace(/[a-z]/g, c => c.toUpperCase());
80
+ if (!PREFIX_RE.test(value))
81
+ throw new MissionKeyError('invalid_prefix', `prefix must be 1-${MISSION_PREFIX_MAX} Latin letters or digits starting with a letter, e.g. OPS`);
82
+ return value;
83
+ }
84
+ /** Latin words of a list name; diacritics are folded, other scripts are dropped. */
85
+ function latinWords(name) {
86
+ return name.normalize('NFKD').replace(/\p{M}/gu, '').toUpperCase()
87
+ .split(/[^A-Z0-9]+/).filter(Boolean);
88
+ }
89
+ /**
90
+ * Deterministic convenience suggestion; never authoritative and never a
91
+ * tombstoned, retired, reserved or active prefix.
92
+ */
93
+ export function suggestMissionPrefix(name, taken) {
94
+ const words = latinWords(name).filter(word => /^[A-Z]/.test(word));
95
+ let base = words.length > 1
96
+ ? words.map(word => word[0]).join('').slice(0, MISSION_PREFIX_MAX)
97
+ : words.length === 1 ? words[0].slice(0, 4) : '';
98
+ if (!PREFIX_RE.test(base))
99
+ base = 'L';
100
+ const free = (candidate) => !taken.has(candidate) && !RESERVED_MISSION_PREFIXES.includes(candidate);
101
+ if (base !== 'L' && free(base))
102
+ return base;
103
+ for (let n = base === 'L' ? 1 : 2; n < 1_000_000; n++) {
104
+ const suffix = String(n);
105
+ const candidate = base.slice(0, MISSION_PREFIX_MAX - suffix.length) + suffix;
106
+ if (PREFIX_RE.test(candidate) && free(candidate))
107
+ return candidate;
108
+ }
109
+ throw new MissionKeyError('key_space_exhausted', 'no free prefix suggestion');
110
+ }
111
+ // ── Registry ─────────────────────────────────────────────────────────────
112
+ export function readMissionKeyRegistry() {
113
+ const value = readJson(registryPath());
114
+ if (value && (value.version !== 1 || typeof value.default_prefix !== 'string' || typeof value.prefixes !== 'object'))
115
+ throw new MissionKeyError('invalid_state', 'invalid mission key registry');
116
+ return value;
117
+ }
118
+ export function writeMissionKeyRegistry(registry) {
119
+ assertLocked();
120
+ writeJson(registryPath(), registry);
121
+ }
122
+ /**
123
+ * Create the registry on first use. A configured default prefix may change the
124
+ * default list's prefix only until it has issued its first key.
125
+ */
126
+ export function ensureMissionKeyRegistry(configuredDefault) {
127
+ return withMissionKeysLock(() => {
128
+ const desired = configuredDefault === undefined ? undefined : normalizeMissionPrefix(configuredDefault);
129
+ let registry = readMissionKeyRegistry();
130
+ if (!registry) {
131
+ const prefix = desired ?? DEFAULT_MISSION_PREFIX;
132
+ registry = { version: 1, default_prefix: prefix,
133
+ prefixes: { [prefix]: { list_id: 'default', last_issued: 0, state: 'active' } } };
134
+ writeMissionKeyRegistry(registry);
135
+ return registry;
136
+ }
137
+ if (desired === undefined || desired === registry.default_prefix)
138
+ return registry;
139
+ const current = registry.prefixes[registry.default_prefix];
140
+ if (current && current.last_issued > 0)
141
+ throw new MissionKeyError('default_prefix_frozen', `default list prefix is ${registry.default_prefix}; tasks.default_prefix=${desired} can only change before the first mission key`);
142
+ if (registry.prefixes[desired])
143
+ throw new MissionKeyError('prefix_reserved', `prefix ${desired} is already used by another list`);
144
+ delete registry.prefixes[registry.default_prefix];
145
+ registry.default_prefix = desired;
146
+ registry.prefixes[desired] = { list_id: 'default', last_issued: 0, state: 'active' };
147
+ writeMissionKeyRegistry(registry);
148
+ return registry;
149
+ });
150
+ }
151
+ /** Every prefix a custom list may not take: reserved names, the default, and any ever-issued prefix. */
152
+ export function unavailablePrefixes(registry) {
153
+ return new Set([...RESERVED_MISSION_PREFIXES, registry.default_prefix, ...Object.keys(registry.prefixes)]);
154
+ }
155
+ export function activePrefixFor(registry, listId) {
156
+ return Object.entries(registry.prefixes).find(([, record]) => record.list_id === listId && record.state === 'active')?.[0];
157
+ }
158
+ /**
159
+ * Claim `prefix` as the active prefix of `listId`. `explicit` is false for a
160
+ * server suggestion, which never adopts a tombstone. Caller holds the lock and
161
+ * writes the registry.
162
+ */
163
+ export function claimPrefix(registry, listId, prefix, explicit) {
164
+ assertLocked();
165
+ if (listId !== 'default' && (RESERVED_MISSION_PREFIXES.includes(prefix) || prefix === registry.default_prefix))
166
+ throw new MissionKeyError('prefix_reserved', `prefix ${prefix} is reserved`);
167
+ const existing = registry.prefixes[prefix];
168
+ if (existing) {
169
+ const ownRetired = existing.list_id === listId && existing.state !== 'tombstoned';
170
+ const reclaim = existing.state === 'tombstoned' && explicit;
171
+ if (existing.list_id === listId && existing.state === 'active')
172
+ return;
173
+ if (!ownRetired && !reclaim)
174
+ throw new MissionKeyError(existing.state === 'active' ? 'duplicate_prefix' : 'prefix_reserved', existing.state === 'active'
175
+ ? `prefix ${prefix} is already used by another list`
176
+ : `prefix ${prefix} was used by an earlier list and is reserved`);
177
+ }
178
+ for (const record of Object.values(registry.prefixes))
179
+ if (record.list_id === listId && record.state === 'active')
180
+ record.state = 'retired';
181
+ registry.prefixes[prefix] = { list_id: listId, last_issued: existing?.last_issued ?? 0, state: 'active' };
182
+ }
183
+ /** Deleting a list keeps its prefixes and high-water marks as tombstones. */
184
+ export function tombstoneListPrefixes(registry, listId) {
185
+ assertLocked();
186
+ for (const record of Object.values(registry.prefixes))
187
+ if (record.list_id === listId)
188
+ record.state = 'tombstoned';
189
+ }
190
+ /** Raise a prefix's high-water mark; the caller persists the registry before publishing the key. */
191
+ export function issueNextKey(registry, listId) {
192
+ assertLocked();
193
+ const prefix = activePrefixFor(registry, listId);
194
+ if (!prefix)
195
+ throw new MissionKeyError('invalid_state', `list ${listId} has no active prefix`);
196
+ const record = registry.prefixes[prefix];
197
+ if (record.last_issued >= MAX_MISSION_NUMBER)
198
+ throw new MissionKeyError('key_space_exhausted', `prefix ${prefix} has issued its last key`);
199
+ record.last_issued += 1;
200
+ return `${prefix}-${record.last_issued}`;
201
+ }
202
+ /** Monotonically account for an already-issued key. */
203
+ export function observeIssuedKey(registry, key, listId) {
204
+ const match = MISSION_KEY_RE.exec(key);
205
+ if (!match)
206
+ return;
207
+ const prefix = match[1].toUpperCase(), n = Number(match[2]);
208
+ const record = registry.prefixes[prefix]
209
+ ?? (registry.prefixes[prefix] = { list_id: listId, last_issued: 0, state: 'tombstoned' });
210
+ record.last_issued = Math.max(record.last_issued, n);
211
+ }
212
+ // ── Receipts and ledger ──────────────────────────────────────────────────
213
+ export const readCreationReceipt = (idempotencyKey) => readJson(receiptPath(idempotencyKey));
214
+ export function writeCreationReceipt(idempotencyKey, receipt) {
215
+ assertLocked();
216
+ const previous = readCreationReceipt(idempotencyKey);
217
+ // A deletion tombstone is terminal: nothing may revive the receipt.
218
+ if (previous?.state === 'deleted' && receipt.state !== 'deleted')
219
+ throw new MissionKeyError('task_deleted', `mission ${previous.mission_key} was deleted`);
220
+ writeJson(receiptPath(idempotencyKey), receipt);
221
+ }
222
+ export function readKeyLedger(key) {
223
+ if (!/^[A-Z][A-Z0-9]{0,5}-[1-9][0-9]*$/.test(key))
224
+ return undefined;
225
+ return readJson(ledgerPath(key));
226
+ }
227
+ /** Create or confirm the ledger entry; a key never moves to another task. */
228
+ export function ensureKeyLedger(key, taskId, state = 'live') {
229
+ assertLocked();
230
+ const previous = readKeyLedger(key);
231
+ if (previous && previous.task_id !== taskId)
232
+ throw new MissionKeyError('invalid_state', `mission key ${key} already belongs to another task`);
233
+ if (previous && (previous.state === state || previous.state === 'deleted'))
234
+ return;
235
+ writeJson(ledgerPath(key), { version: 1, key, task_id: taskId, state });
236
+ }
237
+ export function canonicalFingerprint(input) {
238
+ const sorted = (value) => Array.isArray(value) ? value.map(sorted)
239
+ : value && typeof value === 'object'
240
+ ? Object.fromEntries(Object.keys(value).sort().filter(key => value[key] !== undefined)
241
+ .map(key => [key, sorted(value[key])]))
242
+ : value;
243
+ return createHash('sha256').update(JSON.stringify(sorted(input))).digest('hex');
244
+ }
245
+ /** Parse a user reference; returns the normalized key, or undefined for non-key input. */
246
+ export function parseMissionKeyRef(ref) {
247
+ const match = MISSION_KEY_RE.exec(ref);
248
+ return match ? `${match[1].toUpperCase()}-${match[2]}` : undefined;
249
+ }
250
+ export const missionKeysExist = () => existsSync(registryPath());
@@ -15,6 +15,8 @@ export interface ProvisionMembersInput {
15
15
  startupWait?: Partial<StartupWaitPolicy>;
16
16
  /** Append only the supplied new slots to an already active room. */
17
17
  append?: boolean;
18
+ /** Durable name allocation key for an appended member (`add:<requestId>`). */
19
+ allocationKey?: string;
18
20
  }
19
21
  export interface StartupWaitPolicy {
20
22
  timeoutMs: number;
@@ -23,6 +25,8 @@ export interface StartupWaitPolicy {
23
25
  now(): number;
24
26
  sleep(ms: number): Promise<void>;
25
27
  }
28
+ /** A new name must not collide with any local agent state or a permanent identity. */
29
+ export declare function assertMemberNameAvailable(name: string): void;
26
30
  export declare function provisionMembers(input: ProvisionMembersInput): Promise<RoomOrchestrationRecord>;
27
31
  /** Reconcile proven, admitted launches without any spawn, archive, invite or recovery path. */
28
32
  export declare function reconcileExistingTaskMembers(input: {
@@ -19,7 +19,9 @@ import { SessionControlError } from '../session/types.js';
19
19
  import { closeManagedRoom, roomCloseLockPath, CLOSE_LOCK_STALE_MS } from './close.js';
20
20
  import { withFileLock } from '../atomic-file.js';
21
21
  import { TASK_OPERATION_LOCK_STALE_MS, taskOperationLockPath } from './terminal.js';
22
- import { TaskStateError, taskDeletionState } from './task-state.js';
22
+ import { TaskStateError, allocateTaskMemberNames, taskDeletionState } from './task-state.js';
23
+ import { MemberNameError } from './member-naming.js';
24
+ import { assertMemberNotPermanent } from './member-ownership.js';
23
25
  import { buildRoomMemberTask, sha256Text } from './member-startup.js';
24
26
  import { agentDir } from '../paths.js';
25
27
  import { readProvenance } from '../creation.js';
@@ -92,11 +94,43 @@ function expandMembers(template, prefix) {
92
94
  launchDefinitionId: slot.launch_definition_id ?? slot.agent_template,
93
95
  agentTemplateHash: slot.agent_template_hash,
94
96
  loopSource: slot.loop_source ?? 'omitted',
97
+ ordinal: i,
98
+ candidates: [
99
+ { label: slot.role_preset?.id, source: 'role_override' },
100
+ { label: slot.role_label, source: 'template_ref' },
101
+ { label: slot.role, source: 'room_role' },
102
+ { label: slot.slot, source: 'slot' },
103
+ ],
95
104
  });
96
105
  }
97
106
  }
98
107
  return result;
99
108
  }
109
+ /** A new name must not collide with any local agent state or a permanent identity. */
110
+ export function assertMemberNameAvailable(name) {
111
+ const taken = () => new MemberNameError('agent_name_taken', `agent name ${name} is already used on this host or ours daemon, possibly by another Fleet; give this Fleet's lists distinct prefixes`);
112
+ if (existsSync(agentDir(name)) || existsSync(agentDir(name, true)))
113
+ throw taken();
114
+ try {
115
+ assertMemberNotPermanent(name);
116
+ }
117
+ catch {
118
+ throw taken();
119
+ }
120
+ }
121
+ /**
122
+ * Members of a keyed task take their durable role-scoped names; unkeyed tasks
123
+ * and non-task rooms keep the legacy `prefix-slot-i` derivation.
124
+ */
125
+ function nameMembers(input, members) {
126
+ if (!input.taskId)
127
+ return members;
128
+ const names = allocateTaskMemberNames(input.taskId, members.map(member => ({
129
+ allocationKey: input.append && input.allocationKey ? input.allocationKey : `initial:${member.slot}:${member.ordinal}`,
130
+ candidates: member.candidates,
131
+ })), assertMemberNameAvailable);
132
+ return names ? members.map((member, index) => ({ ...member, name: names[index] })) : members;
133
+ }
100
134
  function settingsFor(member, cfg, sealed) {
101
135
  const definition = sealed
102
136
  ? sealed[member.launchDefinitionId]
@@ -406,7 +440,7 @@ export async function provisionMembers(input) {
406
440
  if (!task || !['active', 'review'].includes(task.state) || room?.state !== 'active'
407
441
  || room.task_id !== task.task_id || room.room_identity_cid !== task.room_identity_cid)
408
442
  throw new Error('Adding an agent requires the exact active task room');
409
- const names = expandMembers(input.template, shortId(task.task_id)).map(m => m.name);
443
+ const names = nameMembers(input, expandMembers(input.template, shortId(task.task_id))).map(m => m.name);
410
444
  if (room.member_seats.some(seat => names.includes(seat.role_name)))
411
445
  throw new Error('Member slot is already recorded; inspect its existing launch');
412
446
  if (room.member_seats.some(seat => seat.seat_state !== 'active'))
@@ -426,7 +460,7 @@ async function provisionMembersUnlocked(input) {
426
460
  if (taskId && taskDeletionState(taskId) !== 'none')
427
461
  throw new Error(`task ${taskId} is pending deletion; refusing to provision members`);
428
462
  const prefix = taskId ? shortId(taskId) : `room-${shortId(roomId)}`;
429
- const members = expandMembers(template, prefix);
463
+ const members = nameMembers(input, expandMembers(template, prefix));
430
464
  // Resolve every Agent before persisting launch intent or touching Cowork membership.
431
465
  const sealed = template.launch_snapshot_hash
432
466
  ? readLaunchSnapshot(template.launch_snapshot_hash) : undefined;
@@ -11,9 +11,25 @@ export declare const withTaskListsLock: <T>(fn: () => T | Promise<T>) => Promise
11
11
  export declare function normalizeTaskListName(input: string): string;
12
12
  export declare function readTaskLists(): TaskListRecord[];
13
13
  export declare function resolveTaskList(name: string, lists?: TaskListRecord[]): TaskListRecord;
14
- /** Caller must hold withTaskListsLock. */
15
- export declare function createTaskListLocked(name: string): TaskListRecord;
14
+ /**
15
+ * Server suggestion for a new list's prefix; convenience only, validated again
16
+ * on create.
17
+ */
18
+ export declare function suggestTaskListPrefix(name: string): string;
19
+ /**
20
+ * Caller must hold withTaskListsLock. The prefix registry is written before
21
+ * the list row, so a crash leaves at worst an orphan reservation that only an
22
+ * explicit reclaim can reuse; a key is never reissued.
23
+ */
24
+ export declare function createTaskListLocked(name: string, prefix?: string): TaskListRecord;
25
+ /**
26
+ * Caller must hold withTaskListsLock. Issued keys keep their old prefix; the
27
+ * previous prefix stays reserved to this list.
28
+ */
29
+ export declare function setTaskListPrefixLocked(listName: string, prefix: string): TaskListRecord;
16
30
  /** Caller must hold withTaskListsLock. */
17
31
  export declare function renameTaskListLocked(currentName: string, nextName: string): TaskListRecord;
18
32
  /** Caller must hold withTaskListsLock. */
19
33
  export declare function deleteTaskListRecordLocked(listId: string): void;
34
+ /** Caller holds withTaskListsLock; persists prefixes assigned by migration. */
35
+ export declare function writeTaskListPrefixesLocked(prefixes: Map<string, string>): TaskListRecord[];