@ours.network/fleet 2.2.6 → 2.3.1

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 (140) hide show
  1. package/README.md +1 -1
  2. package/dist/application/harness-capabilities.d.ts +60 -0
  3. package/dist/application/harness-capabilities.js +96 -0
  4. package/dist/application/model-catalog.d.ts +17 -0
  5. package/dist/application/model-catalog.js +32 -9
  6. package/dist/application/preset-provenance.d.ts +1 -1
  7. package/dist/application/preset-provenance.js +18 -3
  8. package/dist/application/role-creation-service.d.ts +3 -1
  9. package/dist/application/role-creation-service.js +4 -1
  10. package/dist/application/task-room-service.d.ts +12 -1
  11. package/dist/application/task-room-service.js +39 -16
  12. package/dist/atomic-file.d.ts +2 -0
  13. package/dist/atomic-file.js +2 -1
  14. package/dist/build-info.json +4 -4
  15. package/dist/config.d.ts +6 -0
  16. package/dist/config.js +6 -3
  17. package/dist/docs.d.ts +1 -1
  18. package/dist/docs.js +37 -0
  19. package/dist/harness/claude-code.js +7 -0
  20. package/dist/harness/codex.js +7 -0
  21. package/dist/harness/hermes.js +2 -0
  22. package/dist/harness/types.d.ts +20 -0
  23. package/dist/init-wizard.d.ts +19 -12
  24. package/dist/init-wizard.js +122 -104
  25. package/dist/owner-channel/commands.js +25 -12
  26. package/dist/reports/tasks.js +4 -3
  27. package/dist/rooms-tasks/add-member.d.ts +15 -1
  28. package/dist/rooms-tasks/add-member.js +44 -10
  29. package/dist/rooms-tasks/cli.js +79 -5
  30. package/dist/rooms-tasks/config.js +11 -0
  31. package/dist/rooms-tasks/layout-member-retirement.d.ts +15 -0
  32. package/dist/rooms-tasks/layout-member-retirement.js +26 -1
  33. package/dist/rooms-tasks/layout-service.d.ts +2 -1
  34. package/dist/rooms-tasks/layout-service.js +10 -4
  35. package/dist/rooms-tasks/layout.d.ts +4 -2
  36. package/dist/rooms-tasks/layout.js +21 -4
  37. package/dist/rooms-tasks/member-naming.d.ts +43 -0
  38. package/dist/rooms-tasks/member-naming.js +186 -0
  39. package/dist/rooms-tasks/member-overrides.js +1 -0
  40. package/dist/rooms-tasks/mission-keys.d.ts +83 -0
  41. package/dist/rooms-tasks/mission-keys.js +250 -0
  42. package/dist/rooms-tasks/provision.d.ts +4 -0
  43. package/dist/rooms-tasks/provision.js +37 -3
  44. package/dist/rooms-tasks/task-lists.d.ts +18 -2
  45. package/dist/rooms-tasks/task-lists.js +60 -5
  46. package/dist/rooms-tasks/task-room-name.d.ts +7 -2
  47. package/dist/rooms-tasks/task-room-name.js +13 -4
  48. package/dist/rooms-tasks/task-state.d.ts +40 -0
  49. package/dist/rooms-tasks/task-state.js +288 -45
  50. package/dist/rooms-tasks/types.d.ts +35 -1
  51. package/dist/rooms-tasks/types.js +1 -1
  52. package/dist/web/runtime.js +4 -1
  53. package/dist/web/server.js +116 -50
  54. package/package.json +1 -1
  55. package/presets/README.md +38 -18
  56. package/presets/brain-catalog.json +19 -19
  57. package/presets/legacy/generated-brains-rev4.json +497 -0
  58. package/presets/manifest.json +1 -1
  59. package/presets/fleet/brains/claude-fable-5-1-high.yaml +0 -5
  60. package/presets/fleet/brains/claude-fable-5-1-low.yaml +0 -5
  61. package/presets/fleet/brains/claude-fable-5-1-max.yaml +0 -5
  62. package/presets/fleet/brains/claude-fable-5-1-medium.yaml +0 -5
  63. package/presets/fleet/brains/claude-fable-5-1-xhigh.yaml +0 -5
  64. package/presets/fleet/brains/claude-fable-5-high.yaml +0 -5
  65. package/presets/fleet/brains/claude-fable-5-low.yaml +0 -5
  66. package/presets/fleet/brains/claude-fable-5-max.yaml +0 -5
  67. package/presets/fleet/brains/claude-fable-5-medium.yaml +0 -5
  68. package/presets/fleet/brains/claude-fable-5-xhigh.yaml +0 -5
  69. package/presets/fleet/brains/claude-haiku-4-5-20251001-default.yaml +0 -4
  70. package/presets/fleet/brains/claude-opus-5-5-high.yaml +0 -5
  71. package/presets/fleet/brains/claude-opus-5-5-low.yaml +0 -5
  72. package/presets/fleet/brains/claude-opus-5-5-max.yaml +0 -5
  73. package/presets/fleet/brains/claude-opus-5-5-medium.yaml +0 -5
  74. package/presets/fleet/brains/claude-opus-5-5-xhigh.yaml +0 -5
  75. package/presets/fleet/brains/claude-opus-5-high.yaml +0 -5
  76. package/presets/fleet/brains/claude-opus-5-low.yaml +0 -5
  77. package/presets/fleet/brains/claude-opus-5-max.yaml +0 -5
  78. package/presets/fleet/brains/claude-opus-5-medium.yaml +0 -5
  79. package/presets/fleet/brains/claude-opus-5-xhigh.yaml +0 -5
  80. package/presets/fleet/brains/claude-sonnet-5-high.yaml +0 -5
  81. package/presets/fleet/brains/claude-sonnet-5-low.yaml +0 -5
  82. package/presets/fleet/brains/claude-sonnet-5-max.yaml +0 -5
  83. package/presets/fleet/brains/claude-sonnet-5-medium.yaml +0 -5
  84. package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +0 -5
  85. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +0 -5
  86. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +0 -5
  87. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +0 -5
  88. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +0 -5
  89. package/presets/fleet/brains/codex-gpt-5-4-high.yaml +0 -5
  90. package/presets/fleet/brains/codex-gpt-5-4-low.yaml +0 -5
  91. package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +0 -5
  92. package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +0 -5
  93. package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +0 -5
  94. package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +0 -5
  95. package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +0 -5
  96. package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +0 -5
  97. package/presets/fleet/brains/codex-gpt-5-5-high.yaml +0 -5
  98. package/presets/fleet/brains/codex-gpt-5-5-low.yaml +0 -5
  99. package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +0 -5
  100. package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +0 -5
  101. package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +0 -5
  102. package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +0 -5
  103. package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +0 -5
  104. package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +0 -5
  105. package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +0 -5
  106. package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +0 -5
  107. package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +0 -5
  108. package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +0 -5
  109. package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +0 -5
  110. package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +0 -5
  111. package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +0 -5
  112. package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +0 -5
  113. package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +0 -5
  114. package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +0 -5
  115. package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +0 -5
  116. package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +0 -5
  117. package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +0 -5
  118. package/presets/fleet/brains/codex-gpt-6-1-sol-high.yaml +0 -5
  119. package/presets/fleet/brains/codex-gpt-6-1-sol-low.yaml +0 -5
  120. package/presets/fleet/brains/codex-gpt-6-1-sol-max.yaml +0 -5
  121. package/presets/fleet/brains/codex-gpt-6-1-sol-medium.yaml +0 -5
  122. package/presets/fleet/brains/codex-gpt-6-1-sol-ultra.yaml +0 -5
  123. package/presets/fleet/brains/codex-gpt-6-1-sol-xhigh.yaml +0 -5
  124. package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +0 -5
  125. package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +0 -5
  126. package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +0 -5
  127. package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +0 -5
  128. package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +0 -5
  129. package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +0 -5
  130. package/presets/fleet/brains/codex-gpt-6-luna-high.yaml +0 -5
  131. package/presets/fleet/brains/codex-gpt-6-luna-low.yaml +0 -5
  132. package/presets/fleet/brains/codex-gpt-6-luna-max.yaml +0 -5
  133. package/presets/fleet/brains/codex-gpt-6-luna-medium.yaml +0 -5
  134. package/presets/fleet/brains/codex-gpt-6-luna-xhigh.yaml +0 -5
  135. package/presets/fleet/brains/codex-gpt-6-sol-high.yaml +0 -5
  136. package/presets/fleet/brains/codex-gpt-6-sol-low.yaml +0 -5
  137. package/presets/fleet/brains/codex-gpt-6-sol-max.yaml +0 -5
  138. package/presets/fleet/brains/codex-gpt-6-sol-medium.yaml +0 -5
  139. package/presets/fleet/brains/codex-gpt-6-sol-ultra.yaml +0 -5
  140. package/presets/fleet/brains/codex-gpt-6-sol-xhigh.yaml +0 -5
@@ -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[];
@@ -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
- list_id: DEFAULT_TASK_LIST_ID, name: 'default', built_in: true, created_at: CREATED_AT,
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
- /** Caller must hold withTaskListsLock. */
64
- export function createTaskListLocked(name) {
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
- /** Canonical Cowork room name for a Fleet task; never mutates the task title. */
12
- export declare function deriveTaskRoomName(title: string, taskId: string): string;
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
- /** Canonical Cowork room name for a Fleet task; never mutates the task title. */
53
- export function deriveTaskRoomName(title, taskId) {
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
- function writeTask(record) {
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.title}`.slice(0, 160),
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.title}`.slice(0, 160),
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.title}`.slice(0, 160),
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
- const state = input.start === false ? 'backlog' : 'provisioning';
407
- const taskId = generateTaskId();
408
- const record = {
409
- task_id: taskId,
410
- list_id: input.listId ?? DEFAULT_TASK_LIST_ID,
411
- title: input.title,
412
- brief: input.brief,
413
- brief_file: input.brief_file,
414
- state,
415
- template: input.template,
416
- execution_plan: input.execution_plan,
417
- no_room: input.no_room || undefined,
418
- ...(input.layout ? { layout: { name: input.layout.name, run_id: `task-${taskId}`, definition_hash: input.layout.definition_hash } } : {}),
419
- room_id: input.room_id,
420
- member_roles: [],
421
- origin: input.origin,
422
- idempotency_key: key,
423
- creation_plan: requestedPlan,
424
- created_at: new Date().toISOString(),
425
- started_at: state === 'provisioning' ? new Date().toISOString() : undefined,
426
- };
427
- record.workspace = planWorkspace('task', record.task_id);
428
- writeTask(record);
429
- ensureWorkspace(record.workspace);
430
- return readTask(record.task_id);
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, () => {