@ours.network/fleet 2.2.6 → 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.
- package/dist/application/task-room-service.d.ts +11 -0
- package/dist/application/task-room-service.js +37 -14
- package/dist/atomic-file.d.ts +2 -0
- package/dist/atomic-file.js +2 -1
- package/dist/build-info.json +4 -4
- package/dist/config.d.ts +6 -0
- package/dist/config.js +6 -3
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +37 -0
- package/dist/owner-channel/commands.js +25 -12
- package/dist/reports/tasks.js +4 -3
- package/dist/rooms-tasks/add-member.js +17 -6
- package/dist/rooms-tasks/cli.js +79 -5
- package/dist/rooms-tasks/config.js +11 -0
- package/dist/rooms-tasks/layout-member-retirement.d.ts +15 -0
- package/dist/rooms-tasks/layout-member-retirement.js +26 -1
- package/dist/rooms-tasks/layout-service.d.ts +2 -1
- package/dist/rooms-tasks/layout-service.js +10 -4
- package/dist/rooms-tasks/layout.d.ts +4 -2
- package/dist/rooms-tasks/layout.js +21 -4
- package/dist/rooms-tasks/member-naming.d.ts +43 -0
- package/dist/rooms-tasks/member-naming.js +186 -0
- package/dist/rooms-tasks/member-overrides.js +1 -0
- package/dist/rooms-tasks/mission-keys.d.ts +83 -0
- package/dist/rooms-tasks/mission-keys.js +250 -0
- package/dist/rooms-tasks/provision.d.ts +4 -0
- package/dist/rooms-tasks/provision.js +37 -3
- package/dist/rooms-tasks/task-lists.d.ts +18 -2
- package/dist/rooms-tasks/task-lists.js +60 -5
- package/dist/rooms-tasks/task-room-name.d.ts +7 -2
- package/dist/rooms-tasks/task-room-name.js +13 -4
- package/dist/rooms-tasks/task-state.d.ts +40 -0
- package/dist/rooms-tasks/task-state.js +288 -45
- package/dist/rooms-tasks/types.d.ts +35 -1
- package/dist/rooms-tasks/types.js +1 -1
- package/dist/web/runtime.js +4 -1
- package/dist/web/server.js +62 -27
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
|
|
5
5
|
import { stateRoot } from '../paths.js';
|
|
6
|
+
import { claimPrefix, ensureMissionKeyRegistry, normalizeMissionPrefix, readMissionKeyRegistry, suggestMissionPrefix, tombstoneListPrefixes, unavailablePrefixes, withMissionKeysLock, writeMissionKeyRegistry, } from './mission-keys.js';
|
|
6
7
|
export const DEFAULT_TASK_LIST_ID = 'default';
|
|
7
8
|
export const TASK_LIST_NAME_MAX_CODE_POINTS = 64;
|
|
8
9
|
const CREATED_AT = '1970-01-01T00:00:00.000Z';
|
|
@@ -17,9 +18,11 @@ export class TaskListError extends Error {
|
|
|
17
18
|
export const taskListsPath = () => join(stateRoot(), 'task-lists.json');
|
|
18
19
|
export const taskListsLockPath = () => join(stateRoot(), 'task-lists.lock');
|
|
19
20
|
export const withTaskListsLock = (fn) => withFileLock(taskListsLockPath(), fn);
|
|
20
|
-
const defaultList = () =>
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
const defaultList = () => {
|
|
22
|
+
const prefix = readMissionKeyRegistry()?.default_prefix;
|
|
23
|
+
return { list_id: DEFAULT_TASK_LIST_ID, name: 'default', built_in: true, created_at: CREATED_AT,
|
|
24
|
+
...(prefix ? { prefix } : {}) };
|
|
25
|
+
};
|
|
23
26
|
const compareNames = (a, b) => {
|
|
24
27
|
if (a.list_id === DEFAULT_TASK_LIST_ID)
|
|
25
28
|
return b.list_id === DEFAULT_TASK_LIST_ID ? 0 : -1;
|
|
@@ -60,21 +63,60 @@ export function resolveTaskList(name, lists = readTaskLists()) {
|
|
|
60
63
|
throw new TaskListError('list_not_found', `task list not found: ${normalized}`);
|
|
61
64
|
return found;
|
|
62
65
|
}
|
|
63
|
-
/**
|
|
64
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Server suggestion for a new list's prefix; convenience only, validated again
|
|
68
|
+
* on create.
|
|
69
|
+
*/
|
|
70
|
+
export function suggestTaskListPrefix(name) {
|
|
71
|
+
const registry = readMissionKeyRegistry() ?? ensureMissionKeyRegistry();
|
|
72
|
+
return suggestMissionPrefix(typeof name === 'string' ? name : '', unavailablePrefixes(registry));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Caller must hold withTaskListsLock. The prefix registry is written before
|
|
76
|
+
* the list row, so a crash leaves at worst an orphan reservation that only an
|
|
77
|
+
* explicit reclaim can reuse; a key is never reissued.
|
|
78
|
+
*/
|
|
79
|
+
export function createTaskListLocked(name, prefix) {
|
|
65
80
|
const normalized = normalizeTaskListName(name);
|
|
66
81
|
if (normalized === 'default')
|
|
67
82
|
throw new TaskListError('reserved_name', "'default' is a reserved built-in list");
|
|
68
83
|
const lists = readTaskLists();
|
|
69
84
|
if (lists.some(item => item.name === normalized))
|
|
70
85
|
throw new TaskListError('duplicate_name', `task list already exists: ${normalized}`);
|
|
86
|
+
const explicit = prefix !== undefined && prefix !== '';
|
|
87
|
+
const requested = explicit ? normalizeMissionPrefix(prefix) : undefined;
|
|
71
88
|
const record = {
|
|
72
89
|
list_id: `list_${randomUUID().replace(/-/g, '')}`, name: normalized,
|
|
73
90
|
built_in: false, created_at: new Date().toISOString(),
|
|
74
91
|
};
|
|
92
|
+
withMissionKeysLock(() => {
|
|
93
|
+
const registry = ensureMissionKeyRegistry();
|
|
94
|
+
record.prefix = requested ?? suggestMissionPrefix(normalized, unavailablePrefixes(registry));
|
|
95
|
+
claimPrefix(registry, record.list_id, record.prefix, explicit);
|
|
96
|
+
writeMissionKeyRegistry(registry);
|
|
97
|
+
});
|
|
75
98
|
writeTaskLists([...lists, record]);
|
|
76
99
|
return record;
|
|
77
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Caller must hold withTaskListsLock. Issued keys keep their old prefix; the
|
|
103
|
+
* previous prefix stays reserved to this list.
|
|
104
|
+
*/
|
|
105
|
+
export function setTaskListPrefixLocked(listName, prefix) {
|
|
106
|
+
const lists = readTaskLists();
|
|
107
|
+
const current = resolveTaskList(listName, lists);
|
|
108
|
+
if (current.built_in)
|
|
109
|
+
throw new TaskListError('default_immutable', "the 'default' list prefix is set by tasks.default_prefix");
|
|
110
|
+
const next = normalizeMissionPrefix(prefix);
|
|
111
|
+
withMissionKeysLock(() => {
|
|
112
|
+
const registry = ensureMissionKeyRegistry();
|
|
113
|
+
claimPrefix(registry, current.list_id, next, true);
|
|
114
|
+
writeMissionKeyRegistry(registry);
|
|
115
|
+
});
|
|
116
|
+
const updated = { ...current, prefix: next };
|
|
117
|
+
writeTaskLists(lists.map(item => item.list_id === current.list_id ? updated : item));
|
|
118
|
+
return updated;
|
|
119
|
+
}
|
|
78
120
|
/** Caller must hold withTaskListsLock. */
|
|
79
121
|
export function renameTaskListLocked(currentName, nextName) {
|
|
80
122
|
const lists = readTaskLists();
|
|
@@ -92,5 +134,18 @@ export function renameTaskListLocked(currentName, nextName) {
|
|
|
92
134
|
}
|
|
93
135
|
/** Caller must hold withTaskListsLock. */
|
|
94
136
|
export function deleteTaskListRecordLocked(listId) {
|
|
137
|
+
if (readMissionKeyRegistry())
|
|
138
|
+
withMissionKeysLock(() => {
|
|
139
|
+
const registry = readMissionKeyRegistry();
|
|
140
|
+
tombstoneListPrefixes(registry, listId);
|
|
141
|
+
writeMissionKeyRegistry(registry);
|
|
142
|
+
});
|
|
95
143
|
writeTaskLists(readTaskLists().filter(item => item.list_id !== listId));
|
|
96
144
|
}
|
|
145
|
+
/** Caller holds withTaskListsLock; persists prefixes assigned by migration. */
|
|
146
|
+
export function writeTaskListPrefixesLocked(prefixes) {
|
|
147
|
+
const lists = readTaskLists().map(item => prefixes.has(item.list_id) && !item.built_in
|
|
148
|
+
? { ...item, prefix: prefixes.get(item.list_id) } : item);
|
|
149
|
+
writeTaskLists(lists);
|
|
150
|
+
return readTaskLists();
|
|
151
|
+
}
|
|
@@ -8,5 +8,10 @@
|
|
|
8
8
|
export declare const COWORK_ROOM_IDENTITY_PREFIX = "ours-cowork:";
|
|
9
9
|
export declare const COWORK_IDENTITY_NAME_MAX_CODE_POINTS = 64;
|
|
10
10
|
export declare const TASK_ROOM_NAME_MAX_CODE_POINTS: number;
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Canonical Cowork room name for a Fleet task; never mutates the task title.
|
|
13
|
+
* With a mission key the key leads (`MSN-1 Title [id]`), so it survives the
|
|
14
|
+
* room-identity cut; the canonical suffix keeps the name unique on a daemon
|
|
15
|
+
* shared by several Fleets.
|
|
16
|
+
*/
|
|
17
|
+
export declare function deriveTaskRoomName(title: string, taskId: string, missionKey?: string): string;
|
|
@@ -49,13 +49,22 @@ function graphemeBound(value, maxCodePoints) {
|
|
|
49
49
|
}
|
|
50
50
|
return result.trimEnd();
|
|
51
51
|
}
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
const MISSION_KEY_PATTERN = /^[A-Z][A-Z0-9]{0,5}-[1-9][0-9]{0,8}$/;
|
|
53
|
+
/**
|
|
54
|
+
* Canonical Cowork room name for a Fleet task; never mutates the task title.
|
|
55
|
+
* With a mission key the key leads (`MSN-1 Title [id]`), so it survives the
|
|
56
|
+
* room-identity cut; the canonical suffix keeps the name unique on a daemon
|
|
57
|
+
* shared by several Fleets.
|
|
58
|
+
*/
|
|
59
|
+
export function deriveTaskRoomName(title, taskId, missionKey) {
|
|
54
60
|
if (!TASK_ID_PATTERN.test(taskId))
|
|
55
61
|
throw new Error(`invalid canonical task ID: ${taskId}`);
|
|
62
|
+
if (missionKey !== undefined && !MISSION_KEY_PATTERN.test(missionKey))
|
|
63
|
+
throw new Error(`invalid mission key: ${missionKey}`);
|
|
64
|
+
const lead = missionKey ? `${missionKey} ` : '';
|
|
56
65
|
const suffix = ` [${taskId}]`;
|
|
57
|
-
const titleBudget = TASK_ROOM_NAME_MAX_CODE_POINTS - Array.from(suffix).length;
|
|
66
|
+
const titleBudget = TASK_ROOM_NAME_MAX_CODE_POINTS - Array.from(lead + suffix).length;
|
|
58
67
|
const readable = sanitizeTitle(title);
|
|
59
68
|
const bounded = graphemeBound(readable || FALLBACK_TITLE, titleBudget) || FALLBACK_TITLE;
|
|
60
|
-
return `${bounded}${suffix}`;
|
|
69
|
+
return `${lead}${bounded}${suffix}`;
|
|
61
70
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ArchivedMemberAbsence } from './types.js';
|
|
2
2
|
import type { TaskRecord, TaskState, TaskOrigin, TaskTemplateRef, TaskOutcome, TaskMemberRole, TaskTerminalIntent, TaskDeletionActor, TaskDeletionMemberPhase, TaskNotificationEvent } from './types.js';
|
|
3
|
+
import { type LabelCandidate } from './member-naming.js';
|
|
3
4
|
import { type TaskNotificationManifest } from './notification-manifest.js';
|
|
4
5
|
export declare const tasksDir: () => string;
|
|
5
6
|
export declare class TaskStateError extends Error {
|
|
@@ -59,8 +60,47 @@ export interface CreateTaskInput {
|
|
|
59
60
|
};
|
|
60
61
|
room_id?: string;
|
|
61
62
|
listId?: string;
|
|
63
|
+
/** Submitted list name; resolved only after receipt replay, so a renamed or deleted list cannot reallocate. */
|
|
64
|
+
list?: string;
|
|
62
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Create a task, or replay an earlier create with the same idempotency key.
|
|
68
|
+
* Production callers hold `task-lists.lock`; allocation itself is serialized
|
|
69
|
+
* by the innermost mission-keys lock. Ordering: high-water mark → reserved
|
|
70
|
+
* receipt → key ledger → task row → receipt `created`. Every crash cut leaves
|
|
71
|
+
* a gap or a resumable receipt, never a duplicate or a resurrected task.
|
|
72
|
+
*/
|
|
63
73
|
export declare function createTask(input: CreateTaskInput): TaskRecord;
|
|
74
|
+
/**
|
|
75
|
+
* Resolve a canonical task ID or a mission key (any case) to the canonical
|
|
76
|
+
* ID. Callers authenticate before resolving. A deleted key resolves to its
|
|
77
|
+
* canonical ID only with `allowDeleted` (idempotent DELETE replay); every
|
|
78
|
+
* other use gets a stable `task_deleted` error and never another task.
|
|
79
|
+
*/
|
|
80
|
+
export declare function resolveTaskRef(ref: string, options?: {
|
|
81
|
+
allowDeleted?: boolean;
|
|
82
|
+
}): string;
|
|
83
|
+
/**
|
|
84
|
+
* Durable role-scoped names for members of a keyed task. Returns undefined
|
|
85
|
+
* for a task without a mission key (legacy derivation applies). Existing
|
|
86
|
+
* allocations are returned unchanged; new ones are persisted under the task
|
|
87
|
+
* lock before the caller launches anything. `assertAvailable` runs for new
|
|
88
|
+
* names only; a conflict persists nothing and fails safely.
|
|
89
|
+
*/
|
|
90
|
+
export declare function allocateTaskMemberNames(id: string, requests: Array<{
|
|
91
|
+
allocationKey: string;
|
|
92
|
+
candidates: LabelCandidate[];
|
|
93
|
+
}>, assertAvailable?: (name: string) => void): string[] | undefined;
|
|
94
|
+
/** Recorded allocation name for `allocationKey`, without allocating. */
|
|
95
|
+
export declare function recordedMemberName(id: string, allocationKey: string): string | undefined;
|
|
96
|
+
/**
|
|
97
|
+
* Assign prefixes and keys to historical lists and tasks, then seed or repair
|
|
98
|
+
* receipts, ledger entries and frozen agent names for every row. Caller holds
|
|
99
|
+
* `task-lists.lock` (every production path does); per row the lock order is
|
|
100
|
+
* task lock → mission-keys lock, the same as deletion acceptance. Re-runnable:
|
|
101
|
+
* every step is idempotent, and `keys_migrated` is written only at the end.
|
|
102
|
+
*/
|
|
103
|
+
export declare function migrateMissionKeysLocked(configuredDefault?: string): void;
|
|
64
104
|
export declare function getTask(id: string): TaskRecord;
|
|
65
105
|
export declare function updateTaskExecutionPlan(id: string, executionPlan: NonNullable<TaskRecord['execution_plan']>): TaskRecord;
|
|
66
106
|
export declare function listTasks(filter?: {
|
|
@@ -2,12 +2,14 @@ import { publishFleetResourceCommit } from '../application/resource-observer.js'
|
|
|
2
2
|
import { existsSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, rmSync, renameSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { replaceFileAtomically, withSynchronousFileLock } from '../atomic-file.js';
|
|
5
|
+
import { fsyncDirectory, replaceFileAtomically, withSynchronousFileLock } from '../atomic-file.js';
|
|
6
6
|
import { incarnationEnded, processIncarnation } from '../process-incarnation.js';
|
|
7
7
|
import { stateRoot } from '../paths.js';
|
|
8
8
|
import { planWorkspace, ensureWorkspace } from './workspace.js';
|
|
9
9
|
import { storedRoomLaunchPolicy, TASK_TERMINAL_STATES, TASK_CANCELLABLE_STATES } from './types.js';
|
|
10
|
-
import { DEFAULT_TASK_LIST_ID, readTaskLists } from './task-lists.js';
|
|
10
|
+
import { DEFAULT_TASK_LIST_ID, readTaskLists, resolveTaskList, writeTaskListPrefixesLocked } from './task-lists.js';
|
|
11
|
+
import { activePrefixFor, canonicalFingerprint, claimPrefix, ensureKeyLedger, ensureMissionKeyRegistry, issueNextKey, MissionKeyError, observeIssuedKey, parseMissionKeyRef, readCreationReceipt, readKeyLedger, readMissionKeyRegistry, suggestMissionPrefix, unavailablePrefixes, withMissionKeysLock, writeCreationReceipt, writeMissionKeyRegistry, } from './mission-keys.js';
|
|
12
|
+
import { allocateName, freezeLegacyMemberNaming, ownEntry } from './member-naming.js';
|
|
11
13
|
import { queueMissionCleanup, withManifestRooms } from './notification-manifest.js';
|
|
12
14
|
export const tasksDir = () => join(stateRoot(), 'tasks');
|
|
13
15
|
function taskPath(id) { return join(tasksDir(), `${id}.json`); }
|
|
@@ -157,7 +159,8 @@ function readTask(id) {
|
|
|
157
159
|
throw new TaskStateError(`task not found: ${id}`);
|
|
158
160
|
return presentTask(JSON.parse(readFileSync(p, 'utf8')));
|
|
159
161
|
}
|
|
160
|
-
|
|
162
|
+
/** `durable` refuses unless the rename is known durable (agent names are reserved before launch). */
|
|
163
|
+
function writeTask(record, options = {}) {
|
|
161
164
|
mkdirSync(tasksDir(), { recursive: true });
|
|
162
165
|
const persisted = { ...record };
|
|
163
166
|
delete persisted.list_name;
|
|
@@ -175,7 +178,7 @@ function writeTask(record) {
|
|
|
175
178
|
if (!previous || status(previous) !== status(record)) {
|
|
176
179
|
const next = status(record), before = previous ? status(previous) : undefined;
|
|
177
180
|
pending.push({ eventId: `task:${record.task_id}:${randomUUID()}`, taskId: record.task_id,
|
|
178
|
-
title: `Task ${!previous ? 'created' : 'updated'}: ${record
|
|
181
|
+
title: `Task ${!previous ? 'created' : 'updated'}: ${taskLabel(record)}`.slice(0, 160),
|
|
179
182
|
body: `Task ${record.task_id}: ${before ? `${before} → ` : 'created in '}${next}.`,
|
|
180
183
|
target: { kind: 'task', taskId: record.task_id }, delivery: 'push-only' });
|
|
181
184
|
}
|
|
@@ -188,9 +191,11 @@ function writeTask(record) {
|
|
|
188
191
|
persisted.notification_events = pending;
|
|
189
192
|
else
|
|
190
193
|
delete persisted.notification_events;
|
|
191
|
-
replaceFileAtomically(taskPath(record.task_id), JSON.stringify(persisted, null, 2) + '\n');
|
|
194
|
+
replaceFileAtomically(taskPath(record.task_id), JSON.stringify(persisted, null, 2) + '\n', 0o600, {}, { durable: options.durable });
|
|
192
195
|
publishFleetResourceCommit(taskPath(record.task_id));
|
|
193
196
|
}
|
|
197
|
+
/** Human label for notification titles: the mission key, when issued, precedes the title. */
|
|
198
|
+
const taskLabel = (record) => record.key ? `${record.key} · ${record.title}` : record.title;
|
|
194
199
|
/** Packages written before revisions existed count as revision 1; their next change becomes 2. No package is 0. */
|
|
195
200
|
export const reviewRevision = (review) => review ? review.revision ?? 1 : 0;
|
|
196
201
|
const contributionsChanged = (previous, record) => (record.progress ?? []).some(entry => !(previous.progress ?? []).some(old => old.id === entry.id))
|
|
@@ -203,12 +208,12 @@ function contributionEvents(previous, record) {
|
|
|
203
208
|
for (const entry of record.progress ?? []) {
|
|
204
209
|
if ((previous.progress ?? []).some(old => old.id === entry.id))
|
|
205
210
|
continue;
|
|
206
|
-
events.push({ eventId: `task:${id}:progress:${entry.id}`, taskId: id, title: `Progress: ${record
|
|
211
|
+
events.push({ eventId: `task:${id}:progress:${entry.id}`, taskId: id, title: `Progress: ${taskLabel(record)}`.slice(0, 160),
|
|
207
212
|
body: (entry.text.trim() || 'New progress entry').slice(0, 512), target: { kind: 'task', taskId: id }, delivery: 'push-only' });
|
|
208
213
|
}
|
|
209
214
|
const revision = reviewRevision(record.review);
|
|
210
215
|
if (revision > reviewRevision(previous.review))
|
|
211
|
-
events.push({ eventId: `task:${id}:review:${revision}`, taskId: id, title: `To review updated: ${record
|
|
216
|
+
events.push({ eventId: `task:${id}:review:${revision}`, taskId: id, title: `To review updated: ${taskLabel(record)}`.slice(0, 160),
|
|
212
217
|
body: `Task ${id}: the review package changed.`, target: { kind: 'task', taskId: id }, delivery: 'push-only' });
|
|
213
218
|
return events;
|
|
214
219
|
}
|
|
@@ -385,49 +390,270 @@ function creationPlan(plan, layout) {
|
|
|
385
390
|
return JSON.stringify([plan?.plan_hash ?? null, storedRoomLaunchPolicy(plan?.room_policy) ?? null,
|
|
386
391
|
layout ? `${layout.name}@${layout.definition_hash}` : null]);
|
|
387
392
|
}
|
|
393
|
+
const creationFingerprint = (input, plan) => canonicalFingerprint({
|
|
394
|
+
title: input.title, brief: input.brief, brief_file: input.brief_file, template: input.template,
|
|
395
|
+
plan, origin: input.origin, start: input.start !== false, no_room: input.no_room || undefined,
|
|
396
|
+
room_id: input.room_id, list: input.list ?? input.listId ?? DEFAULT_TASK_LIST_ID,
|
|
397
|
+
});
|
|
398
|
+
function recordFromReceipt(receipt, idempotencyKey) {
|
|
399
|
+
const input = receipt.input;
|
|
400
|
+
const listId = readTaskLists().some(item => item.list_id === receipt.list_id) ? receipt.list_id : DEFAULT_TASK_LIST_ID;
|
|
401
|
+
const state = input.start === false ? 'backlog' : 'provisioning';
|
|
402
|
+
const record = {
|
|
403
|
+
task_id: receipt.task_id, key: receipt.mission_key, list_id: listId,
|
|
404
|
+
title: input.title, brief: input.brief, brief_file: input.brief_file, state,
|
|
405
|
+
template: input.template, execution_plan: input.execution_plan, no_room: input.no_room || undefined,
|
|
406
|
+
...(input.layout ? { layout: { name: input.layout.name, run_id: `task-${receipt.task_id}`, definition_hash: input.layout.definition_hash } } : {}),
|
|
407
|
+
room_id: input.room_id, member_roles: [], origin: input.origin,
|
|
408
|
+
idempotency_key: idempotencyKey, creation_plan: receipt.creation_plan,
|
|
409
|
+
created_at: input.created_at, started_at: state === 'provisioning' ? input.created_at : undefined,
|
|
410
|
+
};
|
|
411
|
+
record.workspace = input.workspace ?? planWorkspace('task', record.task_id);
|
|
412
|
+
return record;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Create a task, or replay an earlier create with the same idempotency key.
|
|
416
|
+
* Production callers hold `task-lists.lock`; allocation itself is serialized
|
|
417
|
+
* by the innermost mission-keys lock. Ordering: high-water mark → reserved
|
|
418
|
+
* receipt → key ledger → task row → receipt `created`. Every crash cut leaves
|
|
419
|
+
* a gap or a resumable receipt, never a duplicate or a resurrected task.
|
|
420
|
+
*/
|
|
388
421
|
export function createTask(input) {
|
|
389
422
|
const key = input.idempotency_key ?? randomUUID();
|
|
390
|
-
const existing = listTasks({ includeDeleting: true }).find(task => task.idempotency_key === key);
|
|
391
423
|
const requestedPlan = creationPlan(input.execution_plan, input.layout);
|
|
392
|
-
if (existing) {
|
|
393
|
-
// After a Backlog plan edit the current plan differs from the one created; retries compare the original request.
|
|
394
|
-
if ((existing.creation_plan ?? creationPlan(existing.execution_plan, existing.layout)) !== requestedPlan)
|
|
395
|
-
throw new TaskStateError(`idempotency key '${key}' was already used with a different execution plan`);
|
|
396
|
-
return withTaskLock(existing.task_id, () => {
|
|
397
|
-
const fresh = readTask(existing.task_id);
|
|
398
|
-
assertNoPendingDeletion(fresh);
|
|
399
|
-
if (fresh.workspace)
|
|
400
|
-
ensureWorkspace(fresh.workspace);
|
|
401
|
-
return fresh;
|
|
402
|
-
});
|
|
403
|
-
}
|
|
404
424
|
if (input.layout && (input.execution_plan || input.no_room || input.room_id))
|
|
405
425
|
throw new TaskStateError('a layout task cannot also use a room template, no-room mode or a room');
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
426
|
+
migrateMissionKeysLocked();
|
|
427
|
+
const fingerprint = creationFingerprint(input, requestedPlan);
|
|
428
|
+
const outcome = withMissionKeysLock(() => {
|
|
429
|
+
const receipt = readCreationReceipt(key);
|
|
430
|
+
if (receipt) {
|
|
431
|
+
// Stable policy, unchanged from earlier releases: first write wins and only a different room
|
|
432
|
+
// plan conflicts. A resumed create uses the stored original input, never the retry's fields.
|
|
433
|
+
if (receipt.creation_plan !== undefined && receipt.creation_plan !== requestedPlan)
|
|
434
|
+
throw new TaskStateError(`idempotency key '${key}' was already used with a different execution plan`);
|
|
435
|
+
const present = existsSync(taskPath(receipt.task_id));
|
|
436
|
+
if (receipt.mission_key && (present || receipt.state === 'reserved'))
|
|
437
|
+
ensureKeyLedger(receipt.mission_key, receipt.task_id);
|
|
438
|
+
if (present) {
|
|
439
|
+
if (receipt.state === 'reserved')
|
|
440
|
+
writeCreationReceipt(key, { ...receipt, state: 'created' });
|
|
441
|
+
return { kind: 'existing', taskId: receipt.task_id };
|
|
442
|
+
}
|
|
443
|
+
if (receipt.state !== 'reserved' || readTaskDeletionReceipt(receipt.task_id) || !receipt.input)
|
|
444
|
+
throw new MissionKeyError('task_deleted', `task ${receipt.mission_key ?? receipt.task_id} created with idempotency key '${key}' was deleted`);
|
|
445
|
+
writeTask(recordFromReceipt(receipt, key));
|
|
446
|
+
writeCreationReceipt(key, { ...receipt, state: 'created' });
|
|
447
|
+
return { kind: 'created', taskId: receipt.task_id };
|
|
448
|
+
}
|
|
449
|
+
// Rows written by an earlier release before migration completed.
|
|
450
|
+
const legacy = listTasks({ includeDeleting: true }).find(task => task.idempotency_key === key);
|
|
451
|
+
if (legacy) {
|
|
452
|
+
if ((legacy.creation_plan ?? creationPlan(legacy.execution_plan, legacy.layout)) !== requestedPlan)
|
|
453
|
+
throw new TaskStateError(`idempotency key '${key}' was already used with a different execution plan`);
|
|
454
|
+
return { kind: 'existing', taskId: legacy.task_id };
|
|
455
|
+
}
|
|
456
|
+
const listId = input.listId ?? resolveTaskList(input.list ?? 'default').list_id;
|
|
457
|
+
const registry = ensureMissionKeyRegistry();
|
|
458
|
+
const missionKey = issueNextKey(registry, listId);
|
|
459
|
+
writeMissionKeyRegistry(registry);
|
|
460
|
+
const taskId = generateTaskId();
|
|
461
|
+
const stored = {
|
|
462
|
+
title: input.title, brief: input.brief, brief_file: input.brief_file, template: input.template,
|
|
463
|
+
execution_plan: input.execution_plan, origin: input.origin, start: input.start !== false,
|
|
464
|
+
no_room: input.no_room || undefined, layout: input.layout, room_id: input.room_id,
|
|
465
|
+
list: input.list ?? listId, created_at: new Date().toISOString(),
|
|
466
|
+
workspace: planWorkspace('task', taskId),
|
|
467
|
+
};
|
|
468
|
+
const reserved = { version: 1, task_id: taskId, mission_key: missionKey, list_id: listId,
|
|
469
|
+
fingerprint, input: stored, creation_plan: requestedPlan, state: 'reserved' };
|
|
470
|
+
writeCreationReceipt(key, reserved);
|
|
471
|
+
ensureKeyLedger(missionKey, taskId);
|
|
472
|
+
writeTask(recordFromReceipt(reserved, key));
|
|
473
|
+
writeCreationReceipt(key, { ...reserved, state: 'created' });
|
|
474
|
+
return { kind: 'created', taskId };
|
|
475
|
+
});
|
|
476
|
+
return withTaskLock(outcome.taskId, () => {
|
|
477
|
+
const fresh = readTask(outcome.taskId);
|
|
478
|
+
if (outcome.kind === 'existing')
|
|
479
|
+
assertNoPendingDeletion(fresh);
|
|
480
|
+
if (fresh.workspace)
|
|
481
|
+
ensureWorkspace(fresh.workspace);
|
|
482
|
+
return fresh;
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Resolve a canonical task ID or a mission key (any case) to the canonical
|
|
487
|
+
* ID. Callers authenticate before resolving. A deleted key resolves to its
|
|
488
|
+
* canonical ID only with `allowDeleted` (idempotent DELETE replay); every
|
|
489
|
+
* other use gets a stable `task_deleted` error and never another task.
|
|
490
|
+
*/
|
|
491
|
+
export function resolveTaskRef(ref, options = {}) {
|
|
492
|
+
if (typeof ref !== 'string')
|
|
493
|
+
throw new TaskStateError('invalid task reference');
|
|
494
|
+
if (TASK_ID_PATTERN.test(ref))
|
|
495
|
+
return ref;
|
|
496
|
+
const key = parseMissionKeyRef(ref);
|
|
497
|
+
// Not a key: hand the reference on unchanged so existing not-found/validation errors stay exact.
|
|
498
|
+
if (!key)
|
|
499
|
+
return ref;
|
|
500
|
+
const entry = readKeyLedger(key);
|
|
501
|
+
if (!entry)
|
|
502
|
+
throw new TaskStateError(`task not found: ${key}`);
|
|
503
|
+
if (!options.allowDeleted && entry.state === 'deleted' && !existsSync(taskPath(entry.task_id)))
|
|
504
|
+
throw new MissionKeyError('task_deleted', `task ${key} was deleted`);
|
|
505
|
+
return entry.task_id;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Durable role-scoped names for members of a keyed task. Returns undefined
|
|
509
|
+
* for a task without a mission key (legacy derivation applies). Existing
|
|
510
|
+
* allocations are returned unchanged; new ones are persisted under the task
|
|
511
|
+
* lock before the caller launches anything. `assertAvailable` runs for new
|
|
512
|
+
* names only; a conflict persists nothing and fails safely.
|
|
513
|
+
*/
|
|
514
|
+
export function allocateTaskMemberNames(id, requests, assertAvailable = () => { }) {
|
|
515
|
+
assertCanonicalTaskId(id);
|
|
516
|
+
return withTaskLock(id, () => {
|
|
517
|
+
const task = readTask(id);
|
|
518
|
+
if (!task.key)
|
|
519
|
+
return undefined;
|
|
520
|
+
let naming = task.member_naming;
|
|
521
|
+
const names = [];
|
|
522
|
+
const fresh = [];
|
|
523
|
+
for (const request of requests) {
|
|
524
|
+
const result = allocateName(naming, task.key, request.allocationKey, request.candidates);
|
|
525
|
+
naming = result.naming;
|
|
526
|
+
names.push(result.allocation.name);
|
|
527
|
+
if (result.created)
|
|
528
|
+
fresh.push(result.allocation.name);
|
|
529
|
+
}
|
|
530
|
+
for (const name of fresh)
|
|
531
|
+
assertAvailable(name);
|
|
532
|
+
if (fresh.length) {
|
|
533
|
+
assertNoPendingDeletion(task);
|
|
534
|
+
task.member_naming = naming;
|
|
535
|
+
// A counter that a host crash could roll back would hand the same name out twice.
|
|
536
|
+
writeTask(task, { durable: true });
|
|
537
|
+
}
|
|
538
|
+
else if (names.length) {
|
|
539
|
+
// Reused names may come from a write whose directory sync failed: make it durable before any launch.
|
|
540
|
+
fsyncDirectory(tasksDir());
|
|
541
|
+
}
|
|
542
|
+
return names;
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
/** Recorded allocation name for `allocationKey`, without allocating. */
|
|
546
|
+
export function recordedMemberName(id, allocationKey) {
|
|
547
|
+
assertCanonicalTaskId(id);
|
|
548
|
+
if (!existsSync(taskPath(id)))
|
|
549
|
+
return undefined;
|
|
550
|
+
const row = JSON.parse(readFileSync(taskPath(id), 'utf8'));
|
|
551
|
+
return ownEntry(row.member_naming?.allocations, allocationKey)?.name;
|
|
552
|
+
}
|
|
553
|
+
// ── One-time mission key migration ──────────────────────────────────────
|
|
554
|
+
function listLedgerKeys() {
|
|
555
|
+
const dir = join(stateRoot(), 'mission-keys', 'keys');
|
|
556
|
+
if (!existsSync(dir))
|
|
557
|
+
return new Set();
|
|
558
|
+
return new Set(readdirSync(dir).filter(name => name.endsWith('.json')).map(name => name.slice(0, -5)));
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Assign prefixes and keys to historical lists and tasks, then seed or repair
|
|
562
|
+
* receipts, ledger entries and frozen agent names for every row. Caller holds
|
|
563
|
+
* `task-lists.lock` (every production path does); per row the lock order is
|
|
564
|
+
* task lock → mission-keys lock, the same as deletion acceptance. Re-runnable:
|
|
565
|
+
* every step is idempotent, and `keys_migrated` is written only at the end.
|
|
566
|
+
*/
|
|
567
|
+
export function migrateMissionKeysLocked(configuredDefault) {
|
|
568
|
+
// Lock-free fast path: a completed migration with a matching default prefix needs no writes.
|
|
569
|
+
const current = readMissionKeyRegistry();
|
|
570
|
+
if (current?.keys_migrated && (configuredDefault === undefined || current.default_prefix === configuredDefault))
|
|
571
|
+
return;
|
|
572
|
+
const initial = ensureMissionKeyRegistry(configuredDefault);
|
|
573
|
+
if (initial.keys_migrated)
|
|
574
|
+
return;
|
|
575
|
+
// 1. Prefixes for lists that predate mission keys.
|
|
576
|
+
const assigned = withMissionKeysLock(() => {
|
|
577
|
+
const registry = readMissionKeyRegistry();
|
|
578
|
+
const prefixes = new Map();
|
|
579
|
+
for (const list of readTaskLists()) {
|
|
580
|
+
if (list.built_in)
|
|
581
|
+
continue;
|
|
582
|
+
const existing = activePrefixFor(registry, list.list_id);
|
|
583
|
+
const prefix = list.prefix ?? existing ?? suggestMissionPrefix(list.name, unavailablePrefixes(registry));
|
|
584
|
+
if (!existing)
|
|
585
|
+
claimPrefix(registry, list.list_id, prefix, false);
|
|
586
|
+
prefixes.set(list.list_id, prefix);
|
|
587
|
+
}
|
|
588
|
+
writeMissionKeyRegistry(registry);
|
|
589
|
+
return prefixes;
|
|
590
|
+
});
|
|
591
|
+
writeTaskListPrefixesLocked(assigned);
|
|
592
|
+
// 2. Reconcile every issued key before anything new is issued.
|
|
593
|
+
const rows = (existsSync(tasksDir()) ? readdirSync(tasksDir()) : [])
|
|
594
|
+
.filter(name => name.endsWith('.json')).map(name => name.slice(0, -5))
|
|
595
|
+
.filter(id => TASK_ID_PATTERN.test(id))
|
|
596
|
+
.map(id => ({ id, row: JSON.parse(readFileSync(taskPath(id), 'utf8')) }))
|
|
597
|
+
.sort((a, b) => (a.row.list_id ?? DEFAULT_TASK_LIST_ID).localeCompare(b.row.list_id ?? DEFAULT_TASK_LIST_ID)
|
|
598
|
+
|| a.row.created_at.localeCompare(b.row.created_at) || a.id.localeCompare(b.id));
|
|
599
|
+
withMissionKeysLock(() => {
|
|
600
|
+
const registry = readMissionKeyRegistry();
|
|
601
|
+
for (const { row } of rows) {
|
|
602
|
+
if (row.key)
|
|
603
|
+
observeIssuedKey(registry, row.key, row.list_id ?? DEFAULT_TASK_LIST_ID);
|
|
604
|
+
const receipt = readCreationReceipt(row.idempotency_key);
|
|
605
|
+
if (receipt?.mission_key)
|
|
606
|
+
observeIssuedKey(registry, receipt.mission_key, receipt.list_id);
|
|
607
|
+
}
|
|
608
|
+
for (const key of listLedgerKeys())
|
|
609
|
+
observeIssuedKey(registry, key, DEFAULT_TASK_LIST_ID);
|
|
610
|
+
writeMissionKeyRegistry(registry);
|
|
611
|
+
});
|
|
612
|
+
// 3 + 4. Per row: reserve → receipt → ledger → keyed row, and repair every row.
|
|
613
|
+
for (const { id } of rows) {
|
|
614
|
+
withTaskLock(id, () => withMissionKeysLock(() => {
|
|
615
|
+
if (!existsSync(taskPath(id)))
|
|
616
|
+
return;
|
|
617
|
+
const row = JSON.parse(readFileSync(taskPath(id), 'utf8'));
|
|
618
|
+
const listId = row.list_id ?? DEFAULT_TASK_LIST_ID;
|
|
619
|
+
const deleting = row.deletion?.status === 'pending';
|
|
620
|
+
const prior = readCreationReceipt(row.idempotency_key);
|
|
621
|
+
let missionKey = row.key ?? prior?.mission_key;
|
|
622
|
+
if (!missionKey) {
|
|
623
|
+
const registry = readMissionKeyRegistry();
|
|
624
|
+
const effectiveList = activePrefixFor(registry, listId) ? listId : DEFAULT_TASK_LIST_ID;
|
|
625
|
+
missionKey = issueNextKey(registry, effectiveList);
|
|
626
|
+
writeMissionKeyRegistry(registry);
|
|
627
|
+
}
|
|
628
|
+
if (prior?.state !== 'deleted')
|
|
629
|
+
writeCreationReceipt(row.idempotency_key, {
|
|
630
|
+
version: 1, task_id: id, mission_key: missionKey, list_id: listId,
|
|
631
|
+
...(prior?.fingerprint ? { fingerprint: prior.fingerprint, input: prior.input } : {}),
|
|
632
|
+
creation_plan: row.creation_plan ?? creationPlan(row.execution_plan, row.layout),
|
|
633
|
+
state: deleting ? 'deleted' : 'created',
|
|
634
|
+
});
|
|
635
|
+
ensureKeyLedger(missionKey, id, deleting ? 'deleted' : 'live');
|
|
636
|
+
// Buckets depend on the key's length: derive them from the reserved key, not a placeholder.
|
|
637
|
+
const naming = freezeLegacyMemberNaming({ ...row, key: missionKey });
|
|
638
|
+
if (row.key !== missionKey || naming !== row.member_naming) {
|
|
639
|
+
if (row.key !== missionKey) {
|
|
640
|
+
row.key = missionKey;
|
|
641
|
+
row.key_from_migration = true;
|
|
642
|
+
}
|
|
643
|
+
if (naming)
|
|
644
|
+
row.member_naming = naming;
|
|
645
|
+
writeTask(row, { durable: true });
|
|
646
|
+
}
|
|
647
|
+
}));
|
|
648
|
+
}
|
|
649
|
+
// Rows a crashed or sync-failed earlier pass already renamed are skipped above: confirm them before completion.
|
|
650
|
+
if (rows.length)
|
|
651
|
+
fsyncDirectory(tasksDir());
|
|
652
|
+
withMissionKeysLock(() => {
|
|
653
|
+
const registry = readMissionKeyRegistry();
|
|
654
|
+
registry.keys_migrated = true;
|
|
655
|
+
writeMissionKeyRegistry(registry);
|
|
656
|
+
});
|
|
431
657
|
}
|
|
432
658
|
export function getTask(id) { return withTaskLock(id, () => readTask(id)); }
|
|
433
659
|
export function updateTaskExecutionPlan(id, executionPlan) {
|
|
@@ -861,6 +1087,7 @@ export function beginTaskDeletionIntent(id, actor) {
|
|
|
861
1087
|
}
|
|
862
1088
|
if (stored.deletion?.status === 'pending') {
|
|
863
1089
|
// Heal a crash between the intent write and the receipt write.
|
|
1090
|
+
tombstoneCreation(stored);
|
|
864
1091
|
if (!readTaskDeletionReceipt(id))
|
|
865
1092
|
writeDeletionReceiptForIntent(stored);
|
|
866
1093
|
queueMissionCleanup(id, notificationRoomCids(stored));
|
|
@@ -876,6 +1103,8 @@ export function beginTaskDeletionIntent(id, actor) {
|
|
|
876
1103
|
name: member.name, identity_cid: member.identity_cid, phase: 'pending', updated_at: now,
|
|
877
1104
|
})),
|
|
878
1105
|
};
|
|
1106
|
+
// Tombstone the create receipt and key first, so no create retry can resurrect this task.
|
|
1107
|
+
tombstoneCreation(stored);
|
|
879
1108
|
// The task write drops queued transitions; the cleanup intent then retires everything already delivered.
|
|
880
1109
|
writeTask(stored);
|
|
881
1110
|
queueMissionCleanup(id, notificationRoomCids(stored));
|
|
@@ -883,6 +1112,20 @@ export function beginTaskDeletionIntent(id, actor) {
|
|
|
883
1112
|
return { status: 'accepted', task: presentTaskLenient(stored) };
|
|
884
1113
|
});
|
|
885
1114
|
}
|
|
1115
|
+
/** Terminal `deleted` receipt and ledger state; seeds a receipt for rows that predate receipts. */
|
|
1116
|
+
function tombstoneCreation(stored) {
|
|
1117
|
+
withMissionKeysLock(() => {
|
|
1118
|
+
const receipt = readCreationReceipt(stored.idempotency_key);
|
|
1119
|
+
const key = stored.key ?? receipt?.mission_key;
|
|
1120
|
+
writeCreationReceipt(stored.idempotency_key, {
|
|
1121
|
+
...(receipt ?? { version: 1, task_id: stored.task_id, list_id: stored.list_id ?? DEFAULT_TASK_LIST_ID,
|
|
1122
|
+
creation_plan: stored.creation_plan ?? creationPlan(stored.execution_plan, stored.layout) }),
|
|
1123
|
+
mission_key: key, state: 'deleted',
|
|
1124
|
+
});
|
|
1125
|
+
if (key)
|
|
1126
|
+
ensureKeyLedger(key, stored.task_id, 'deleted');
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
886
1129
|
/** Record an actionable settlement failure; the task stays hidden and recoverable. */
|
|
887
1130
|
export function setTaskDeletionError(id, error, recoveryHint) {
|
|
888
1131
|
return withTaskLock(id, () => {
|