@lumenflow/core 1.3.5 → 1.3.6

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.
@@ -9,6 +9,7 @@
9
9
  * - in_progress → blocked (block)
10
10
  * - in_progress → waiting (implementation complete, awaiting sign-off)
11
11
  * - in_progress → done (direct completion)
12
+ * - in_progress → ready (release - WU-1080: orphan recovery)
12
13
  * - blocked → in_progress (unblock)
13
14
  * - blocked → done (blocker resolved, direct completion)
14
15
  * - waiting → in_progress (changes requested)
@@ -9,6 +9,7 @@
9
9
  * - in_progress → blocked (block)
10
10
  * - in_progress → waiting (implementation complete, awaiting sign-off)
11
11
  * - in_progress → done (direct completion)
12
+ * - in_progress → ready (release - WU-1080: orphan recovery)
12
13
  * - blocked → in_progress (unblock)
13
14
  * - blocked → done (blocker resolved, direct completion)
14
15
  * - waiting → in_progress (changes requested)
@@ -26,7 +27,7 @@ const VALID_STATES = new Set(['ready', 'in_progress', 'blocked', 'waiting', 'don
26
27
  */
27
28
  const TRANSITIONS = {
28
29
  ready: ['in_progress'],
29
- in_progress: ['blocked', 'waiting', 'done'],
30
+ in_progress: ['blocked', 'waiting', 'done', 'ready'], // WU-1080: 'ready' via release for orphan recovery
30
31
  blocked: ['in_progress', 'done'],
31
32
  waiting: ['in_progress', 'done'],
32
33
  done: [], // Terminal state - no outgoing transitions
@@ -84,14 +84,34 @@ export interface RepairWUInconsistencyOptions {
84
84
  projectRoot?: string;
85
85
  }
86
86
  /**
87
- * Repair WU inconsistencies
87
+ * Error object structure from checkWUConsistency()
88
+ */
89
+ interface ConsistencyError {
90
+ type: string;
91
+ wuId: string;
92
+ title?: string;
93
+ lane?: string;
94
+ description?: string;
95
+ repairAction?: string;
96
+ canAutoRepair: boolean;
97
+ }
98
+ /**
99
+ * Repair WU inconsistencies using micro-worktree isolation (WU-1078)
100
+ *
101
+ * All file modifications (stamps, YAML, markdown) are made atomically
102
+ * in a micro-worktree, then committed and pushed to origin/main.
103
+ * This prevents direct writes to the main checkout.
88
104
  *
89
105
  * @param {object} report - Report from checkWUConsistency()
90
106
  * @param {RepairWUInconsistencyOptions} [options={}] - Repair options
91
107
  * @returns {Promise<object>} Result with repaired, skipped, and failed counts
92
108
  */
93
- export declare function repairWUInconsistency(report: any, options?: RepairWUInconsistencyOptions): Promise<{
109
+ export declare function repairWUInconsistency(report: {
110
+ valid: boolean;
111
+ errors: ConsistencyError[];
112
+ }, options?: RepairWUInconsistencyOptions): Promise<{
94
113
  repaired: number;
95
114
  skipped: number;
96
115
  failed: number;
97
116
  }>;
117
+ export {};
@@ -13,13 +13,14 @@
13
13
  * @see {@link ../wu-repair.mjs} CLI interface
14
14
  */
15
15
  import { readFile, writeFile, readdir, mkdir, access } from 'node:fs/promises';
16
- import { constants } from 'node:fs';
16
+ import { constants, existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
17
17
  import path from 'node:path';
18
18
  import { parseYAML, stringifyYAML } from './wu-yaml.js';
19
19
  import { WU_PATHS } from './wu-paths.js';
20
20
  import { CONSISTENCY_TYPES, CONSISTENCY_MESSAGES, LOG_PREFIX, REMOTES, STRING_LITERALS, toKebab, WU_STATUS, YAML_OPTIONS, } from './wu-constants.js';
21
21
  import { todayISO } from './date-utils.js';
22
22
  import { createGitForPath } from './git-adapter.js';
23
+ import { withMicroWorktree } from './micro-worktree.js';
23
24
  /**
24
25
  * Check a single WU for state inconsistencies
25
26
  *
@@ -238,7 +239,34 @@ export async function checkLaneForOrphanDoneWU(lane, excludeId, projectRoot = pr
238
239
  };
239
240
  }
240
241
  /**
241
- * Repair WU inconsistencies
242
+ * Categorize errors into file-based repairs (need micro-worktree) and git-only repairs
243
+ */
244
+ function categorizeErrors(errors) {
245
+ const fileRepairs = [];
246
+ const gitOnlyRepairs = [];
247
+ const nonRepairable = [];
248
+ for (const error of errors) {
249
+ if (!error.canAutoRepair) {
250
+ nonRepairable.push(error);
251
+ continue;
252
+ }
253
+ // Git-only repairs: worktree/branch cleanup doesn't need micro-worktree
254
+ if (error.type === CONSISTENCY_TYPES.ORPHAN_WORKTREE_DONE) {
255
+ gitOnlyRepairs.push(error);
256
+ }
257
+ else {
258
+ // All file-based repairs need micro-worktree isolation
259
+ fileRepairs.push(error);
260
+ }
261
+ }
262
+ return { fileRepairs, gitOnlyRepairs, nonRepairable };
263
+ }
264
+ /**
265
+ * Repair WU inconsistencies using micro-worktree isolation (WU-1078)
266
+ *
267
+ * All file modifications (stamps, YAML, markdown) are made atomically
268
+ * in a micro-worktree, then committed and pushed to origin/main.
269
+ * This prevents direct writes to the main checkout.
242
270
  *
243
271
  * @param {object} report - Report from checkWUConsistency()
244
272
  * @param {RepairWUInconsistencyOptions} [options={}] - Repair options
@@ -249,20 +277,72 @@ export async function repairWUInconsistency(report, options = {}) {
249
277
  if (report.valid) {
250
278
  return { repaired: 0, skipped: 0, failed: 0 };
251
279
  }
280
+ const { fileRepairs, gitOnlyRepairs, nonRepairable } = categorizeErrors(report.errors);
252
281
  let repaired = 0;
253
- let skipped = 0;
282
+ let skipped = nonRepairable.length;
254
283
  let failed = 0;
255
- for (const error of report.errors) {
256
- if (!error.canAutoRepair) {
257
- skipped++;
258
- continue;
284
+ // Dry run mode: just count
285
+ if (dryRun) {
286
+ return {
287
+ repaired: fileRepairs.length + gitOnlyRepairs.length,
288
+ skipped,
289
+ failed: 0,
290
+ };
291
+ }
292
+ // Step 1: Process file-based repairs via micro-worktree (batched)
293
+ if (fileRepairs.length > 0) {
294
+ try {
295
+ // Generate a batch ID from the WU IDs being repaired
296
+ const batchId = `batch-${fileRepairs.map((e) => e.wuId).join('-')}`.slice(0, 50);
297
+ await withMicroWorktree({
298
+ operation: 'wu-repair',
299
+ id: batchId,
300
+ logPrefix: LOG_PREFIX.REPAIR,
301
+ execute: async ({ worktreePath }) => {
302
+ const filesModified = [];
303
+ for (const error of fileRepairs) {
304
+ try {
305
+ const result = await repairSingleErrorInWorktree(error, worktreePath, projectRoot);
306
+ if (result.success && result.files) {
307
+ filesModified.push(...result.files);
308
+ repaired++;
309
+ }
310
+ else if (result.skipped) {
311
+ skipped++;
312
+ if (result.reason) {
313
+ console.warn(`${LOG_PREFIX.REPAIR} Skipped ${error.type}: ${result.reason}`);
314
+ }
315
+ }
316
+ else {
317
+ failed++;
318
+ }
319
+ }
320
+ catch (err) {
321
+ const errMessage = err instanceof Error ? err.message : String(err);
322
+ console.error(`${LOG_PREFIX.REPAIR} Failed to repair ${error.type}: ${errMessage}`);
323
+ failed++;
324
+ }
325
+ }
326
+ // Deduplicate files
327
+ const uniqueFiles = [...new Set(filesModified)];
328
+ return {
329
+ commitMessage: `fix: repair ${repaired} WU inconsistencies`,
330
+ files: uniqueFiles,
331
+ };
332
+ },
333
+ });
259
334
  }
260
- if (dryRun) {
261
- repaired++;
262
- continue;
335
+ catch (err) {
336
+ // If micro-worktree fails, mark all file repairs as failed
337
+ const errMessage = err instanceof Error ? err.message : String(err);
338
+ console.error(`${LOG_PREFIX.REPAIR} Micro-worktree operation failed: ${errMessage}`);
339
+ failed += fileRepairs.length - repaired;
263
340
  }
341
+ }
342
+ // Step 2: Process git-only repairs (worktree/branch cleanup) directly
343
+ for (const error of gitOnlyRepairs) {
264
344
  try {
265
- const result = await repairSingleError(error, projectRoot);
345
+ const result = await repairGitOnlyError(error, projectRoot);
266
346
  if (result.success) {
267
347
  repaired++;
268
348
  }
@@ -285,34 +365,85 @@ export async function repairWUInconsistency(report, options = {}) {
285
365
  return { repaired, skipped, failed };
286
366
  }
287
367
  /**
288
- * Repair a single inconsistency error
368
+ * Repair a single file-based error inside a micro-worktree (WU-1078)
369
+ *
370
+ * This function performs file modifications inside the worktree path,
371
+ * which is then committed and pushed atomically by withMicroWorktree.
372
+ *
373
+ * @param {ConsistencyError} error - Error object from checkWUConsistency()
374
+ * @param {string} worktreePath - Path to the micro-worktree
375
+ * @param {string} projectRoot - Original project root (for reading source files)
376
+ * @returns {Promise<RepairResult>} Result with success, skipped, reason, and files modified
377
+ */
378
+ async function repairSingleErrorInWorktree(error, worktreePath, projectRoot) {
379
+ switch (error.type) {
380
+ case CONSISTENCY_TYPES.YAML_DONE_NO_STAMP: {
381
+ const files = await createStampInWorktree(error.wuId, error.title || `WU ${error.wuId}`, worktreePath);
382
+ return { success: true, files };
383
+ }
384
+ case CONSISTENCY_TYPES.YAML_DONE_STATUS_IN_PROGRESS: {
385
+ const files = await removeWUFromSectionInWorktree(WU_PATHS.STATUS(), error.wuId, '## In Progress', worktreePath, projectRoot);
386
+ return { success: true, files };
387
+ }
388
+ case CONSISTENCY_TYPES.BACKLOG_DUAL_SECTION: {
389
+ const files = await removeWUFromSectionInWorktree(WU_PATHS.BACKLOG(), error.wuId, '## 🔧 In progress', worktreePath, projectRoot);
390
+ return { success: true, files };
391
+ }
392
+ case CONSISTENCY_TYPES.STAMP_EXISTS_YAML_NOT_DONE: {
393
+ const files = await updateYamlToDoneInWorktree(error.wuId, worktreePath, projectRoot);
394
+ return { success: true, files };
395
+ }
396
+ default:
397
+ return { skipped: true, reason: `Unknown error type: ${error.type}` };
398
+ }
399
+ }
400
+ /**
401
+ * Repair git-only errors (worktree/branch cleanup) without micro-worktree
402
+ *
403
+ * These operations don't modify files in the repo, they only manage git worktrees
404
+ * and branches, so they can run directly.
289
405
  *
290
- * @param {object} error - Error object from checkWUConsistency()
406
+ * @param {ConsistencyError} error - Error object
291
407
  * @param {string} projectRoot - Project root directory
292
408
  * @returns {Promise<RepairResult>} Result with success, skipped, and reason
293
409
  */
294
- async function repairSingleError(error, projectRoot) {
410
+ async function repairGitOnlyError(error, projectRoot) {
295
411
  switch (error.type) {
296
- case CONSISTENCY_TYPES.YAML_DONE_NO_STAMP:
297
- await createStampInProject(error.wuId, error.title || `WU ${error.wuId}`, projectRoot);
298
- return { success: true };
299
- case CONSISTENCY_TYPES.YAML_DONE_STATUS_IN_PROGRESS:
300
- await removeWUFromSection(path.join(projectRoot, WU_PATHS.STATUS()), error.wuId, '## In Progress');
301
- return { success: true };
302
- case CONSISTENCY_TYPES.BACKLOG_DUAL_SECTION:
303
- await removeWUFromSection(path.join(projectRoot, WU_PATHS.BACKLOG()), error.wuId, '## 🔧 In progress');
304
- return { success: true };
305
412
  case CONSISTENCY_TYPES.ORPHAN_WORKTREE_DONE:
306
413
  return await removeOrphanWorktree(error.wuId, error.lane, projectRoot);
307
- case CONSISTENCY_TYPES.STAMP_EXISTS_YAML_NOT_DONE:
308
- await updateYamlToDone(error.wuId, projectRoot);
309
- return { success: true };
310
414
  default:
311
- return { skipped: true, reason: `Unknown error type: ${error.type}` };
415
+ return { skipped: true, reason: `Unknown git-only error type: ${error.type}` };
312
416
  }
313
417
  }
314
418
  /**
315
- * Create stamp file in a specific project root
419
+ * Create stamp file inside a micro-worktree (WU-1078)
420
+ *
421
+ * @param {string} id - WU ID
422
+ * @param {string} title - WU title
423
+ * @param {string} worktreePath - Path to the micro-worktree
424
+ * @returns {Promise<string[]>} List of files created (relative paths)
425
+ */
426
+ async function createStampInWorktree(id, title, worktreePath) {
427
+ const stampsDir = path.join(worktreePath, WU_PATHS.STAMPS_DIR());
428
+ const stampRelPath = WU_PATHS.STAMP(id);
429
+ const stampAbsPath = path.join(worktreePath, stampRelPath);
430
+ // Ensure stamps directory exists
431
+ if (!existsSync(stampsDir)) {
432
+ mkdirSync(stampsDir, { recursive: true });
433
+ }
434
+ // Don't overwrite existing stamp
435
+ if (existsSync(stampAbsPath)) {
436
+ return []; // Stamp already exists
437
+ }
438
+ // Create stamp file
439
+ const body = `WU ${id} — ${title}\nCompleted: ${todayISO()}\n`;
440
+ writeFileSync(stampAbsPath, body, { encoding: 'utf-8' });
441
+ return [stampRelPath];
442
+ }
443
+ /**
444
+ * Create stamp file in a specific project root (DEPRECATED - use createStampInWorktree)
445
+ *
446
+ * Kept for backwards compatibility with code that doesn't use micro-worktree.
316
447
  *
317
448
  * @param {string} id - WU ID
318
449
  * @param {string} title - WU title
@@ -342,7 +473,7 @@ async function createStampInProject(id, title, projectRoot) {
342
473
  await writeFile(stampPath, body, { encoding: 'utf-8' });
343
474
  }
344
475
  /**
345
- * Update WU YAML to done+locked+completed state (WU-2412)
476
+ * Update WU YAML to done+locked+completed state inside a micro-worktree (WU-1078)
346
477
  *
347
478
  * Repairs STAMP_EXISTS_YAML_NOT_DONE by setting:
348
479
  * - status: done
@@ -350,6 +481,43 @@ async function createStampInProject(id, title, projectRoot) {
350
481
  * - completed: YYYY-MM-DD (today, unless already set)
351
482
  *
352
483
  * @param {string} id - WU ID
484
+ * @param {string} worktreePath - Path to the micro-worktree
485
+ * @param {string} projectRoot - Original project root (for reading source file)
486
+ * @returns {Promise<string[]>} List of files modified (relative paths)
487
+ */
488
+ async function updateYamlToDoneInWorktree(id, worktreePath, projectRoot) {
489
+ const wuRelPath = WU_PATHS.WU(id);
490
+ const wuSrcPath = path.join(projectRoot, wuRelPath);
491
+ const wuDestPath = path.join(worktreePath, wuRelPath);
492
+ // Read current YAML from project root
493
+ const content = readFileSync(wuSrcPath, { encoding: 'utf-8' });
494
+ const wuDoc = parseYAML(content);
495
+ if (!wuDoc) {
496
+ throw new Error(`Failed to parse WU YAML: ${wuSrcPath}`);
497
+ }
498
+ // Update fields
499
+ wuDoc.status = WU_STATUS.DONE;
500
+ wuDoc.locked = true;
501
+ // Preserve existing completed date if present, otherwise set to today
502
+ if (!wuDoc.completed) {
503
+ wuDoc.completed = todayISO();
504
+ }
505
+ // Ensure directory exists in worktree
506
+ const wuDir = path.dirname(wuDestPath);
507
+ if (!existsSync(wuDir)) {
508
+ mkdirSync(wuDir, { recursive: true });
509
+ }
510
+ // Write updated YAML to worktree
511
+ const updatedContent = stringifyYAML(wuDoc, { lineWidth: YAML_OPTIONS.LINE_WIDTH });
512
+ writeFileSync(wuDestPath, updatedContent, { encoding: 'utf-8' });
513
+ return [wuRelPath];
514
+ }
515
+ /**
516
+ * Update WU YAML to done+locked+completed state (DEPRECATED - use updateYamlToDoneInWorktree)
517
+ *
518
+ * Kept for backwards compatibility.
519
+ *
520
+ * @param {string} id - WU ID
353
521
  * @param {string} projectRoot - Project root directory
354
522
  * @returns {Promise<void>}
355
523
  */
@@ -373,7 +541,69 @@ async function updateYamlToDone(id, projectRoot) {
373
541
  await writeFile(wuPath, updatedContent, { encoding: 'utf-8' });
374
542
  }
375
543
  /**
376
- * Remove WU entry from a specific section in a markdown file
544
+ * Remove WU entry from a specific section in a markdown file inside a micro-worktree (WU-1078)
545
+ *
546
+ * @param {string} relFilePath - Relative path to the markdown file
547
+ * @param {string} id - WU ID to remove
548
+ * @param {string} sectionHeading - Section heading to target
549
+ * @param {string} worktreePath - Path to the micro-worktree
550
+ * @param {string} projectRoot - Original project root (for reading source file)
551
+ * @returns {Promise<string[]>} List of files modified (relative paths)
552
+ */
553
+ async function removeWUFromSectionInWorktree(relFilePath, id, sectionHeading, worktreePath, projectRoot) {
554
+ const srcPath = path.join(projectRoot, relFilePath);
555
+ const destPath = path.join(worktreePath, relFilePath);
556
+ // Check if source file exists
557
+ if (!existsSync(srcPath)) {
558
+ return []; // File doesn't exist
559
+ }
560
+ const content = readFileSync(srcPath, { encoding: 'utf-8' });
561
+ const lines = content.split(/\r?\n/);
562
+ let inTargetSection = false;
563
+ let nextSectionIdx = -1;
564
+ let sectionStartIdx = -1;
565
+ // Normalize heading for comparison (lowercase, trim)
566
+ const normalizedHeading = sectionHeading.toLowerCase().trim();
567
+ // Find section boundaries
568
+ for (let i = 0; i < lines.length; i++) {
569
+ const normalizedLine = lines[i].toLowerCase().trim();
570
+ if (normalizedLine === normalizedHeading || normalizedLine.startsWith(normalizedHeading)) {
571
+ inTargetSection = true;
572
+ sectionStartIdx = i;
573
+ continue;
574
+ }
575
+ if (inTargetSection && lines[i].trim().startsWith('## ')) {
576
+ nextSectionIdx = i;
577
+ break;
578
+ }
579
+ }
580
+ if (sectionStartIdx === -1)
581
+ return [];
582
+ const endIdx = nextSectionIdx === -1 ? lines.length : nextSectionIdx;
583
+ // Filter out lines containing the WU ID in the target section
584
+ const newLines = [];
585
+ let modified = false;
586
+ for (let i = 0; i < lines.length; i++) {
587
+ if (i > sectionStartIdx && i < endIdx && lines[i].includes(id)) {
588
+ modified = true;
589
+ continue; // Skip this line
590
+ }
591
+ newLines.push(lines[i]);
592
+ }
593
+ if (!modified)
594
+ return [];
595
+ // Ensure directory exists in worktree
596
+ const destDir = path.dirname(destPath);
597
+ if (!existsSync(destDir)) {
598
+ mkdirSync(destDir, { recursive: true });
599
+ }
600
+ writeFileSync(destPath, newLines.join(STRING_LITERALS.NEWLINE), { encoding: 'utf-8' });
601
+ return [relFilePath];
602
+ }
603
+ /**
604
+ * Remove WU entry from a specific section in a markdown file (DEPRECATED)
605
+ *
606
+ * Kept for backwards compatibility.
377
607
  *
378
608
  * @param {string} filePath - Path to the markdown file
379
609
  * @param {string} id - WU ID to remove
@@ -17,8 +17,9 @@ import { z } from 'zod';
17
17
  * - complete: WU completed (transitions to done)
18
18
  * - checkpoint: Progress checkpoint (WU-1748: cross-agent visibility)
19
19
  * - spawn: WU spawned from parent (WU-1947: parent-child relationships)
20
+ * - release: WU released (WU-1080: transitions from in_progress to ready for orphan recovery)
20
21
  */
21
- export declare const WU_EVENT_TYPES: readonly ["create", "claim", "block", "unblock", "complete", "checkpoint", "spawn"];
22
+ export declare const WU_EVENT_TYPES: readonly ["create", "claim", "block", "unblock", "complete", "checkpoint", "spawn", "release"];
22
23
  /** Type for WU event types */
23
24
  export type WUEventType = (typeof WU_EVENT_TYPES)[number];
24
25
  /**
@@ -103,6 +104,17 @@ export declare const SpawnEventSchema: z.ZodObject<{
103
104
  parentWuId: z.ZodString;
104
105
  spawnId: z.ZodString;
105
106
  }, z.core.$strip>;
107
+ /**
108
+ * Release event schema (WU-1080: orphan recovery)
109
+ * Releases an in_progress WU back to ready state when agent is interrupted.
110
+ * Allows another agent to reclaim the orphaned WU.
111
+ */
112
+ export declare const ReleaseEventSchema: z.ZodObject<{
113
+ wuId: z.ZodString;
114
+ timestamp: z.ZodString;
115
+ type: z.ZodLiteral<"release">;
116
+ reason: z.ZodString;
117
+ }, z.core.$strip>;
106
118
  /**
107
119
  * Union schema for all event types
108
120
  */
@@ -145,6 +157,11 @@ export declare const WUEventSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
145
157
  type: z.ZodLiteral<"spawn">;
146
158
  parentWuId: z.ZodString;
147
159
  spawnId: z.ZodString;
160
+ }, z.core.$strip>, z.ZodObject<{
161
+ wuId: z.ZodString;
162
+ timestamp: z.ZodString;
163
+ type: z.ZodLiteral<"release">;
164
+ reason: z.ZodString;
148
165
  }, z.core.$strip>], "type">;
149
166
  /**
150
167
  * TypeScript types inferred from schemas
@@ -156,6 +173,7 @@ export type UnblockEvent = z.infer<typeof UnblockEventSchema>;
156
173
  export type CompleteEvent = z.infer<typeof CompleteEventSchema>;
157
174
  export type CheckpointEvent = z.infer<typeof CheckpointEventSchema>;
158
175
  export type SpawnEvent = z.infer<typeof SpawnEventSchema>;
176
+ export type ReleaseEvent = z.infer<typeof ReleaseEventSchema>;
159
177
  export type WUEvent = z.infer<typeof WUEventSchema>;
160
178
  /**
161
179
  * Validates WU event data against schema
@@ -210,4 +228,9 @@ export declare function validateWUEvent(data: any): z.ZodSafeParseResult<{
210
228
  type: "spawn";
211
229
  parentWuId: string;
212
230
  spawnId: string;
231
+ } | {
232
+ wuId: string;
233
+ timestamp: string;
234
+ type: "release";
235
+ reason: string;
213
236
  }>;
@@ -17,6 +17,7 @@ import { z } from 'zod';
17
17
  * - complete: WU completed (transitions to done)
18
18
  * - checkpoint: Progress checkpoint (WU-1748: cross-agent visibility)
19
19
  * - spawn: WU spawned from parent (WU-1947: parent-child relationships)
20
+ * - release: WU released (WU-1080: transitions from in_progress to ready for orphan recovery)
20
21
  */
21
22
  export const WU_EVENT_TYPES = [
22
23
  'create',
@@ -26,6 +27,7 @@ export const WU_EVENT_TYPES = [
26
27
  'complete',
27
28
  'checkpoint',
28
29
  'spawn',
30
+ 'release',
29
31
  ];
30
32
  /**
31
33
  * WU status values (matches LumenFlow state machine)
@@ -125,6 +127,16 @@ export const SpawnEventSchema = BaseEventSchema.extend({
125
127
  /** Unique spawn identifier */
126
128
  spawnId: z.string().min(1, { message: 'Spawn ID is required' }),
127
129
  });
130
+ /**
131
+ * Release event schema (WU-1080: orphan recovery)
132
+ * Releases an in_progress WU back to ready state when agent is interrupted.
133
+ * Allows another agent to reclaim the orphaned WU.
134
+ */
135
+ export const ReleaseEventSchema = BaseEventSchema.extend({
136
+ type: z.literal('release'),
137
+ /** Reason for releasing the WU */
138
+ reason: z.string().min(1, { message: ERROR_MESSAGES.REASON_REQUIRED }),
139
+ });
128
140
  /**
129
141
  * Union schema for all event types
130
142
  */
@@ -136,6 +148,7 @@ export const WUEventSchema = z.discriminatedUnion('type', [
136
148
  CompleteEventSchema,
137
149
  CheckpointEventSchema,
138
150
  SpawnEventSchema,
151
+ ReleaseEventSchema,
139
152
  ]);
140
153
  /**
141
154
  * Validates WU event data against schema
@@ -217,6 +217,27 @@ export declare class WUStateStore {
217
217
  * await store.spawn('WU-200', 'WU-100', 'spawn-abc123');
218
218
  */
219
219
  spawn(childWuId: string, parentWuId: string, spawnId: string): Promise<void>;
220
+ /**
221
+ * Releases an in_progress WU back to ready state (WU-1080: orphan recovery).
222
+ *
223
+ * Use this when an agent is interrupted mid-WU and the WU needs to be
224
+ * made available for reclaiming by another agent.
225
+ *
226
+ * @throws Error If WU is not in_progress
227
+ *
228
+ * @example
229
+ * await store.release('WU-1080', 'Agent interrupted mid-WU');
230
+ */
231
+ release(wuId: string, reason: string): Promise<void>;
232
+ /**
233
+ * Create a release event without writing to disk.
234
+ *
235
+ * Used by transactional flows where event log writes are staged and committed atomically.
236
+ * WU-1080: Orphan recovery support.
237
+ *
238
+ * @throws Error If WU is not in_progress or event fails validation
239
+ */
240
+ createReleaseEvent(wuId: string, reason: string, timestamp?: string): WUEvent;
220
241
  }
221
242
  /**
222
243
  * Check if a lock is stale (expired or dead process)
@@ -171,6 +171,11 @@ export class WUStateStore {
171
171
  this.byParent.set(parentWuId, new Set());
172
172
  }
173
173
  this.byParent.get(parentWuId).add(wuId);
174
+ return;
175
+ }
176
+ // WU-1080: Handle release event - transitions from in_progress to ready
177
+ if (type === 'release') {
178
+ this._transitionToStatus(wuId, 'ready');
174
179
  }
175
180
  }
176
181
  /**
@@ -446,6 +451,55 @@ export class WUStateStore {
446
451
  await this._appendEvent(event);
447
452
  this._applyEvent(event);
448
453
  }
454
+ /**
455
+ * Releases an in_progress WU back to ready state (WU-1080: orphan recovery).
456
+ *
457
+ * Use this when an agent is interrupted mid-WU and the WU needs to be
458
+ * made available for reclaiming by another agent.
459
+ *
460
+ * @throws Error If WU is not in_progress
461
+ *
462
+ * @example
463
+ * await store.release('WU-1080', 'Agent interrupted mid-WU');
464
+ */
465
+ async release(wuId, reason) {
466
+ // Check state machine: can only release if in_progress
467
+ const currentState = this.wuState.get(wuId);
468
+ if (!currentState || currentState.status !== 'in_progress') {
469
+ throw new Error(`WU ${wuId} is not in_progress`);
470
+ }
471
+ const event = {
472
+ type: 'release',
473
+ wuId,
474
+ reason,
475
+ timestamp: new Date().toISOString(),
476
+ };
477
+ await this._appendEvent(event);
478
+ this._applyEvent(event);
479
+ }
480
+ /**
481
+ * Create a release event without writing to disk.
482
+ *
483
+ * Used by transactional flows where event log writes are staged and committed atomically.
484
+ * WU-1080: Orphan recovery support.
485
+ *
486
+ * @throws Error If WU is not in_progress or event fails validation
487
+ */
488
+ createReleaseEvent(wuId, reason, timestamp = new Date().toISOString()) {
489
+ const currentState = this.wuState.get(wuId);
490
+ if (!currentState || currentState.status !== 'in_progress') {
491
+ throw new Error(`WU ${wuId} is not in_progress`);
492
+ }
493
+ const event = { type: 'release', wuId, reason, timestamp };
494
+ const validation = validateWUEvent(event);
495
+ if (!validation.success) {
496
+ const issues = validation.error.issues
497
+ .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
498
+ .join(', ');
499
+ throw new Error(`Validation error: ${issues}`);
500
+ }
501
+ return validation.data;
502
+ }
449
503
  }
450
504
  /**
451
505
  * Check if a process with given PID is running
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumenflow/core",
3
- "version": "1.3.5",
3
+ "version": "1.3.6",
4
4
  "description": "Core WU lifecycle tools for LumenFlow workflow framework",
5
5
  "keywords": [
6
6
  "lumenflow",
@@ -91,8 +91,8 @@
91
91
  "vitest": "^4.0.17"
92
92
  },
93
93
  "peerDependencies": {
94
- "@lumenflow/memory": "1.3.5",
95
- "@lumenflow/initiatives": "1.3.5"
94
+ "@lumenflow/memory": "1.3.6",
95
+ "@lumenflow/initiatives": "1.3.6"
96
96
  },
97
97
  "peerDependenciesMeta": {
98
98
  "@lumenflow/memory": {