@worca/ui 0.12.0 → 0.14.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.
@@ -22,6 +22,8 @@ import { tmpdir } from 'node:os';
22
22
  import { join, resolve } from 'node:path';
23
23
 
24
24
  import { dispatchExternal } from './dispatch-external.js';
25
+ import { readGlobalSettings } from './settings-reader.js';
26
+ import { removeWorktree } from './worktree-ops.js';
25
27
 
26
28
  /** Byte threshold — must match claude_cli.py _ARG_INLINE_LIMIT */
27
29
  const ARG_INLINE_LIMIT = 128 * 1024;
@@ -78,10 +80,48 @@ export class ProcessManager {
78
80
  /**
79
81
  * @param {{ worcaDir: string, projectRoot?: string, settingsPath?: string }} options
80
82
  */
81
- constructor({ worcaDir, projectRoot, settingsPath }) {
83
+ constructor({ worcaDir, projectRoot, settingsPath, prefsDir }) {
82
84
  this.worcaDir = worcaDir;
83
85
  this.projectRoot = projectRoot || process.cwd();
84
86
  this.settingsPath = settingsPath ?? null;
87
+ this.prefsDir = prefsDir ?? null;
88
+ }
89
+
90
+ /**
91
+ * Resolve the worcaDir and runDir for a given run ID.
92
+ * Checks root runs/ first, then pipelines.d/ registry for worktree_path.
93
+ * @param {string} runId
94
+ * @returns {{ worcaDir: string, runDir: string } | null}
95
+ */
96
+ resolveRunContext(runId) {
97
+ const rootPath = join(this.worcaDir, 'runs', runId, 'status.json');
98
+ if (existsSync(rootPath)) {
99
+ return {
100
+ worcaDir: this.worcaDir,
101
+ runDir: join(this.worcaDir, 'runs', runId),
102
+ };
103
+ }
104
+ const regPath = join(
105
+ this.worcaDir,
106
+ 'multi',
107
+ 'pipelines.d',
108
+ `${runId}.json`,
109
+ );
110
+ if (existsSync(regPath)) {
111
+ try {
112
+ const reg = JSON.parse(readFileSync(regPath, 'utf8'));
113
+ if (reg.worktree_path) {
114
+ const wtWorcaDir = join(reg.worktree_path, '.worca');
115
+ return {
116
+ worcaDir: wtWorcaDir,
117
+ runDir: join(wtWorcaDir, 'runs', runId),
118
+ };
119
+ }
120
+ } catch {
121
+ /* ignore */
122
+ }
123
+ }
124
+ return null;
85
125
  }
86
126
 
87
127
  /**
@@ -90,10 +130,17 @@ export class ProcessManager {
90
130
  * @returns {{ pid: number } | null}
91
131
  */
92
132
  getRunningPid(runId) {
93
- // Build candidate PID paths: per-run first, then project-level fallback
133
+ // Build candidate PID paths: per-run first (with worktree overlay),
134
+ // then project-level fallback. Worktree runs live under
135
+ // <worktree_path>/.worca/runs/<id>/ and are routed via pipelines.d/.
94
136
  const candidates = [];
95
137
  if (runId) {
96
- candidates.push(join(this.worcaDir, 'runs', runId, 'pipeline.pid'));
138
+ const ctx = this.resolveRunContext(runId);
139
+ if (ctx) {
140
+ candidates.push(join(ctx.runDir, 'pipeline.pid'));
141
+ } else {
142
+ candidates.push(join(this.worcaDir, 'runs', runId, 'pipeline.pid'));
143
+ }
97
144
  }
98
145
  candidates.push(join(this.worcaDir, 'pipeline.pid'));
99
146
 
@@ -125,7 +172,7 @@ export class ProcessManager {
125
172
 
126
173
  /**
127
174
  * Reconcile stale "running" status when the pipeline process is dead.
128
- * Scans all runs with per-run PID files + the active_run pointer.
175
+ * Scans all runs with per-run PID files.
129
176
  * If pipeline_status is "running" but no process is alive, transitions
130
177
  * to "failed" with stop_reason="stale".
131
178
  * Preserves any existing stop_reason (e.g. "signal" set by Layer 1).
@@ -136,7 +183,7 @@ export class ProcessManager {
136
183
  let fixed = false;
137
184
  const dispatches = [];
138
185
 
139
- // Collect run IDs to check: scan runs/*/pipeline.pid + active_run fallback
186
+ // Collect run IDs to check: scan runs/*/pipeline.pid
140
187
  const runIds = new Set();
141
188
  const runsDir = join(this.worcaDir, 'runs');
142
189
  if (existsSync(runsDir)) {
@@ -154,17 +201,6 @@ export class ProcessManager {
154
201
  }
155
202
  }
156
203
 
157
- // Backward compat: also check active_run pointer
158
- const activeRunPath = join(this.worcaDir, 'active_run');
159
- if (existsSync(activeRunPath)) {
160
- try {
161
- const activeId = readFileSync(activeRunPath, 'utf8').trim();
162
- if (activeId) runIds.add(activeId);
163
- } catch {
164
- /* ignore */
165
- }
166
- }
167
-
168
204
  for (const runId of runIds) {
169
205
  // Check if this run's process is alive
170
206
  const alive = this.getRunningPid(runId);
@@ -259,12 +295,76 @@ export class ProcessManager {
259
295
  }
260
296
  }
261
297
  }
298
+
299
+ this.maybeAutoCleanup(runId);
262
300
  }
263
301
 
264
302
  await Promise.all(dispatches);
265
303
  return fixed;
266
304
  }
267
305
 
306
+ /**
307
+ * Post-completion cleanup hook (§5b).
308
+ * When cleanup_policy is 'on-success' and the run completed cleanly,
309
+ * removes the worktree via worktree-ops and emits a worktree.auto_cleanup
310
+ * event. 'never' (default) and 'manual-only' are both no-ops.
311
+ * @param {string} runId
312
+ * @returns {{ cleaned: boolean, runId?: string, path?: string, reason?: string }}
313
+ */
314
+ maybeAutoCleanup(runId) {
315
+ const ctx = this.resolveRunContext(runId);
316
+ const runDir = ctx ? ctx.runDir : join(this.worcaDir, 'runs', runId);
317
+ const statusPath = join(runDir, 'status.json');
318
+
319
+ if (!existsSync(statusPath)) return { cleaned: false };
320
+
321
+ let status;
322
+ try {
323
+ status = JSON.parse(readFileSync(statusPath, 'utf8'));
324
+ } catch {
325
+ return { cleaned: false };
326
+ }
327
+
328
+ const worktreePath = status.worktree_path;
329
+ if (!worktreePath) return { cleaned: false };
330
+
331
+ const exitOk = status.pipeline_status === 'completed';
332
+ if (!exitOk) return { cleaned: false };
333
+
334
+ let policy = 'never';
335
+ if (this.prefsDir) {
336
+ try {
337
+ const globalPrefs = readGlobalSettings(
338
+ join(this.prefsDir, 'settings.json'),
339
+ );
340
+ policy = globalPrefs?.worca?.parallel?.cleanup_policy ?? 'never';
341
+ } catch {
342
+ // Fall back to default 'never'
343
+ }
344
+ }
345
+
346
+ if (policy !== 'on-success') return { cleaned: false };
347
+
348
+ removeWorktree(this.worcaDir, runId);
349
+
350
+ try {
351
+ const eventsPath = join(runDir, 'events.jsonl');
352
+ const evt = {
353
+ schema_version: '1',
354
+ event_id: randomUUID(),
355
+ event_type: 'worktree.auto_cleanup',
356
+ timestamp: new Date().toISOString(),
357
+ run_id: status.run_id ?? runId,
358
+ payload: { runId, path: worktreePath, reason: 'on-success' },
359
+ };
360
+ appendFileSync(eventsPath, `${JSON.stringify(evt)}\n`, 'utf8');
361
+ } catch {
362
+ /* non-fatal */
363
+ }
364
+
365
+ return { cleaned: true, runId, path: worktreePath, reason: 'on-success' };
366
+ }
367
+
268
368
  /**
269
369
  * Start a new pipeline run.
270
370
  * @param {{ inputType?: string, inputValue?: string, msize?: number, mloops?: number, planFile?: string, resume?: boolean, projectRoot?: string }} opts
@@ -272,20 +372,47 @@ export class ProcessManager {
272
372
  */
273
373
  async startPipeline(opts = {}) {
274
374
  const cwd = opts.projectRoot || this.projectRoot;
275
- const scriptPath = join(cwd, '.claude/worca/scripts/run_pipeline.py');
276
- if (!existsSync(scriptPath)) {
277
- const err = new Error(`Pipeline script not found at ${scriptPath}`);
278
- err.code = 'script_not_found';
279
- throw err;
375
+ const pipelineScriptRel = '.claude/worca/scripts/run_pipeline.py';
376
+ const worktreeScriptRel = '.claude/worca/scripts/run_worktree.py';
377
+
378
+ let scriptRel;
379
+ if (opts.resume) {
380
+ const pipelinePath = join(cwd, pipelineScriptRel);
381
+ if (!existsSync(pipelinePath)) {
382
+ const err = new Error(`Pipeline script not found at ${pipelinePath}`);
383
+ err.code = 'script_not_found';
384
+ throw err;
385
+ }
386
+ scriptRel = pipelineScriptRel;
387
+ } else {
388
+ const worktreePath = join(cwd, worktreeScriptRel);
389
+ if (existsSync(worktreePath)) {
390
+ scriptRel = worktreeScriptRel;
391
+ } else {
392
+ const pipelinePath = join(cwd, pipelineScriptRel);
393
+ if (!existsSync(pipelinePath)) {
394
+ const err = new Error(`Pipeline script not found at ${pipelinePath}`);
395
+ err.code = 'script_not_found';
396
+ throw err;
397
+ }
398
+ console.warn(
399
+ '[worca] run_worktree.py not found, falling back to run_pipeline.py',
400
+ );
401
+ scriptRel = pipelineScriptRel;
402
+ }
280
403
  }
281
404
 
282
- const args = ['.claude/worca/scripts/run_pipeline.py'];
405
+ const args = [scriptRel];
283
406
  let promptFilePath = null; // track for cleanup on spawn failure
284
407
 
285
408
  if (opts.resume) {
286
409
  args.push('--resume');
287
410
  if (opts.runId) {
288
- args.push('--status-dir', join(this.worcaDir, 'runs', opts.runId));
411
+ const ctx = this.resolveRunContext(opts.runId);
412
+ const statusDir = ctx
413
+ ? ctx.runDir
414
+ : join(this.worcaDir, 'runs', opts.runId);
415
+ args.push('--status-dir', statusDir);
289
416
  }
290
417
  } else if (opts.sourceType !== undefined) {
291
418
  // New format: separate source and prompt args
@@ -400,10 +527,13 @@ export class ProcessManager {
400
527
  let pid = null;
401
528
  let foundPidPath = null;
402
529
 
530
+ const ctx = runId ? this.resolveRunContext(runId) : null;
531
+ const effectiveWorcaDir = ctx ? ctx.worcaDir : this.worcaDir;
532
+
403
533
  // Check per-run PID file first, then project-level fallback
404
534
  const candidates = [];
405
535
  if (runId) {
406
- candidates.push(join(this.worcaDir, 'runs', runId, 'pipeline.pid'));
536
+ candidates.push(join(effectiveWorcaDir, 'runs', runId, 'pipeline.pid'));
407
537
  }
408
538
  candidates.push(join(this.worcaDir, 'pipeline.pid'));
409
539
 
@@ -431,10 +561,10 @@ export class ProcessManager {
431
561
  }
432
562
 
433
563
  // Belt-and-suspenders: write control.json so the orchestrator gets a clean signal
434
- const effectiveRunId = runId || this._readActiveRunId();
564
+ const effectiveRunId = runId;
435
565
  if (effectiveRunId) {
436
566
  try {
437
- const controlDir = join(this.worcaDir, 'runs', effectiveRunId);
567
+ const controlDir = join(effectiveWorcaDir, 'runs', effectiveRunId);
438
568
  mkdirSync(controlDir, { recursive: true });
439
569
  writeFileSync(
440
570
  join(controlDir, 'control.json'),
@@ -471,14 +601,17 @@ export class ProcessManager {
471
601
  // Fire-and-forget: reconcileStatus is async but we intentionally don't
472
602
  // await it — this is a background cleanup path after the response is sent.
473
603
  const worcaDir = this.worcaDir;
474
- const { settingsPath } = this;
604
+ const { settingsPath, prefsDir } = this;
475
605
  const watchdog = setTimeout(() => {
476
606
  try {
477
607
  process.kill(pid, 0); // check alive
478
608
  process.kill(pid, 'SIGKILL');
479
- setTimeout(() => reconcileStatus(worcaDir, settingsPath), 500);
609
+ setTimeout(
610
+ () => reconcileStatus(worcaDir, settingsPath, prefsDir),
611
+ 500,
612
+ );
480
613
  } catch {
481
- reconcileStatus(worcaDir, settingsPath);
614
+ reconcileStatus(worcaDir, settingsPath, prefsDir);
482
615
  }
483
616
  }, 10000);
484
617
  watchdog.unref();
@@ -514,7 +647,9 @@ export class ProcessManager {
514
647
  }
515
648
  const { pid } = running;
516
649
 
517
- const controlDir = join(this.worcaDir, 'runs', runId);
650
+ const ctx = this.resolveRunContext(runId);
651
+ const effectiveWorcaDir = ctx ? ctx.worcaDir : this.worcaDir;
652
+ const controlDir = join(effectiveWorcaDir, 'runs', runId);
518
653
  mkdirSync(controlDir, { recursive: true });
519
654
  writeFileSync(
520
655
  join(controlDir, 'control.json'),
@@ -569,21 +704,6 @@ export class ProcessManager {
569
704
  }
570
705
  }
571
706
 
572
- /**
573
- * Read the active_run file to get the current run ID.
574
- * @returns {string|null}
575
- */
576
- _readActiveRunId() {
577
- const activeRunPath = join(this.worcaDir, 'active_run');
578
- if (!existsSync(activeRunPath)) return null;
579
- try {
580
- const id = readFileSync(activeRunPath, 'utf8').trim();
581
- return id || null;
582
- } catch {
583
- return null;
584
- }
585
- }
586
-
587
707
  /**
588
708
  * Delete a run directory and clean up references.
589
709
  * Refuses if the pipeline is currently running.
@@ -600,8 +720,10 @@ export class ProcessManager {
600
720
  throw err;
601
721
  }
602
722
 
603
- const runsParent = resolve(this.worcaDir, 'runs');
604
- const runDir = resolve(runsParent, runId);
723
+ const ctx = this.resolveRunContext(runId);
724
+ const effectiveWorcaDir = ctx ? ctx.worcaDir : this.worcaDir;
725
+ const runsParent = resolve(effectiveWorcaDir, 'runs');
726
+ const runDir = ctx ? resolve(ctx.runDir) : resolve(runsParent, runId);
605
727
  if (!runDir.startsWith(runsParent)) {
606
728
  const err = new Error('Invalid runId');
607
729
  err.code = 'invalid_id';
@@ -615,17 +737,6 @@ export class ProcessManager {
615
737
 
616
738
  rmSync(runDir, { recursive: true, force: true });
617
739
 
618
- // Clear active_run pointer if it references this run
619
- const activeRunPath = join(this.worcaDir, 'active_run');
620
- if (existsSync(activeRunPath)) {
621
- try {
622
- const activeId = readFileSync(activeRunPath, 'utf8').trim();
623
- if (activeId === runId) unlinkSync(activeRunPath);
624
- } catch {
625
- /* ignore */
626
- }
627
- }
628
-
629
740
  return { deleted: true };
630
741
  }
631
742
 
@@ -635,7 +746,8 @@ export class ProcessManager {
635
746
  * @returns {{ runId: string, paused: boolean }}
636
747
  */
637
748
  pausePipeline(runId) {
638
- const controlDir = join(this.worcaDir, 'runs', runId);
749
+ const ctx = this.resolveRunContext(runId);
750
+ const controlDir = ctx ? ctx.runDir : join(this.worcaDir, 'runs', runId);
639
751
  mkdirSync(controlDir, { recursive: true });
640
752
  writeFileSync(
641
753
  join(controlDir, 'control.json'),
@@ -655,19 +767,34 @@ export class ProcessManager {
655
767
 
656
768
  /**
657
769
  * Restart a failed stage by resetting it and spawning with --resume.
770
+ *
771
+ * Internal API — only called from worca-ui (project-routes.js). The signature
772
+ * changed in W-048 from (stageKey, opts) to (runId, stageKey, opts) because
773
+ * runs are now per-worktree and the manager can no longer infer "the active
774
+ * run". Callers must pass an explicit runId.
775
+ *
776
+ * @param {string} runId - Run identifier to restart
658
777
  * @param {string} stageKey - The stage key to restart
659
778
  * @param {{ projectRoot?: string }} opts
660
779
  * @returns {Promise<{ pid: number, stage: string }>}
661
780
  */
662
- async restartStage(stageKey, opts = {}) {
663
- const running = this.getRunningPid();
781
+ async restartStage(runId, stageKey, opts = {}) {
782
+ const running = this.getRunningPid(runId);
664
783
  if (running) {
665
784
  const err = new Error(`Pipeline already running (PID ${running.pid})`);
666
785
  err.code = 'already_running';
667
786
  throw err;
668
787
  }
669
788
 
670
- const cwd = opts.projectRoot || this.projectRoot;
789
+ const ctx = this.resolveRunContext(runId);
790
+ const runDir = ctx ? ctx.runDir : join(this.worcaDir, 'runs', runId);
791
+ // For worktree runs derive projectRoot from worcaDir parent (.worca/..)
792
+ const cwd =
793
+ opts.projectRoot ||
794
+ (ctx && ctx.worcaDir !== this.worcaDir
795
+ ? join(ctx.worcaDir, '..')
796
+ : this.projectRoot);
797
+
671
798
  const scriptPath = join(cwd, '.claude/worca/scripts/run_pipeline.py');
672
799
  if (!existsSync(scriptPath)) {
673
800
  const err = new Error(`Pipeline script not found at ${scriptPath}`);
@@ -675,24 +802,8 @@ export class ProcessManager {
675
802
  throw err;
676
803
  }
677
804
 
678
- // Find status.json — check active_run first, then legacy
679
- let statusPath = null;
680
- const activeRunPath = join(this.worcaDir, 'active_run');
681
- if (existsSync(activeRunPath)) {
682
- try {
683
- const runId = readFileSync(activeRunPath, 'utf8').trim();
684
- const candidate = join(this.worcaDir, 'runs', runId, 'status.json');
685
- if (existsSync(candidate)) statusPath = candidate;
686
- } catch {
687
- /* ignore */
688
- }
689
- }
690
- if (!statusPath) {
691
- const legacy = join(this.worcaDir, 'status.json');
692
- if (existsSync(legacy)) statusPath = legacy;
693
- }
694
-
695
- if (!statusPath) {
805
+ const statusPath = join(runDir, 'status.json');
806
+ if (!existsSync(statusPath)) {
696
807
  const err = new Error('No status.json found');
697
808
  err.code = 'no_status';
698
809
  throw err;
@@ -720,14 +831,19 @@ export class ProcessManager {
720
831
  delete status.stages[stageKey].completed_at;
721
832
  writeFileSync(statusPath, `${JSON.stringify(status, null, 2)}\n`, 'utf8');
722
833
 
723
- // Spawn with --resume
834
+ // Spawn with --resume --status-dir so the pipeline finds the right run
724
835
  const env = { ...process.env };
725
836
  delete env.CLAUDECODE;
726
837
 
727
838
  return new Promise((resolve, reject) => {
728
839
  const child = spawn(
729
840
  'python3',
730
- ['.claude/worca/scripts/run_pipeline.py', '--resume'],
841
+ [
842
+ '.claude/worca/scripts/run_pipeline.py',
843
+ '--resume',
844
+ '--status-dir',
845
+ runDir,
846
+ ],
731
847
  {
732
848
  detached: true,
733
849
  stdio: 'ignore',
@@ -784,9 +900,13 @@ export function getRunningPid(worcaDir, runId) {
784
900
  return new ProcessManager({ worcaDir }).getRunningPid(runId);
785
901
  }
786
902
 
787
- /** @param {string} worcaDir @param {string} [settingsPath] */
788
- export function reconcileStatus(worcaDir, settingsPath) {
789
- return new ProcessManager({ worcaDir, settingsPath }).reconcileStatus();
903
+ /** @param {string} worcaDir @param {string} [settingsPath] @param {string} [prefsDir] */
904
+ export function reconcileStatus(worcaDir, settingsPath, prefsDir) {
905
+ return new ProcessManager({
906
+ worcaDir,
907
+ settingsPath,
908
+ prefsDir,
909
+ }).reconcileStatus();
790
910
  }
791
911
 
792
912
  /** @param {string} worcaDir @param {object} opts */
@@ -807,10 +927,10 @@ export function pausePipeline(worcaDir, runId) {
807
927
  return new ProcessManager({ worcaDir }).pausePipeline(runId);
808
928
  }
809
929
 
810
- /** @param {string} worcaDir @param {string} stageKey @param {object} opts */
811
- export async function restartStage(worcaDir, stageKey, opts = {}) {
930
+ /** @param {string} worcaDir @param {string} runId @param {string} stageKey @param {object} opts */
931
+ export async function restartStage(worcaDir, runId, stageKey, opts = {}) {
812
932
  return new ProcessManager({
813
933
  worcaDir,
814
934
  projectRoot: opts.projectRoot,
815
- }).restartStage(stageKey, opts);
935
+ }).restartStage(runId, stageKey, opts);
816
936
  }
@@ -0,0 +1,92 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { atomicWriteSync } from './atomic-write.js';
4
+
5
+ function isPidAlive(pid) {
6
+ try {
7
+ process.kill(pid, 0);
8
+ return true;
9
+ } catch (err) {
10
+ if (err.code === 'EPERM') return true;
11
+ return false;
12
+ }
13
+ }
14
+
15
+ function clearStalePid(statusPath, status) {
16
+ try {
17
+ const patched = {
18
+ ...status,
19
+ pipeline_status: 'error',
20
+ error: 'Stale PID: process no longer running',
21
+ };
22
+ atomicWriteSync(statusPath, `${JSON.stringify(patched, null, 2)}\n`);
23
+ } catch {
24
+ // best-effort
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Count running pipelines across all registered projects.
30
+ * Walks ~/.worca/projects.d/, checks each project's .worca/runs/ for
31
+ * status.json entries with pipeline_status=running, and verifies PID liveness.
32
+ * Prunes stale PIDs (dead processes still marked as running).
33
+ */
34
+ export function countRunningPipelinesAcrossProjects(prefsDir) {
35
+ const projectsDir = join(prefsDir, 'projects.d');
36
+ if (!existsSync(projectsDir)) return 0;
37
+
38
+ let entries;
39
+ try {
40
+ entries = readdirSync(projectsDir);
41
+ } catch {
42
+ return 0;
43
+ }
44
+
45
+ let count = 0;
46
+
47
+ for (const file of entries) {
48
+ if (!file.endsWith('.json')) continue;
49
+
50
+ let project;
51
+ try {
52
+ project = JSON.parse(readFileSync(join(projectsDir, file), 'utf-8'));
53
+ } catch {
54
+ continue;
55
+ }
56
+
57
+ if (!project || typeof project.path !== 'string') continue;
58
+
59
+ const runsDir = join(project.path, '.worca', 'runs');
60
+ if (!existsSync(runsDir)) continue;
61
+
62
+ let runEntries;
63
+ try {
64
+ runEntries = readdirSync(runsDir);
65
+ } catch {
66
+ continue;
67
+ }
68
+
69
+ for (const runEntry of runEntries) {
70
+ const statusPath = join(runsDir, runEntry, 'status.json');
71
+ if (!existsSync(statusPath)) continue;
72
+
73
+ let status;
74
+ try {
75
+ status = JSON.parse(readFileSync(statusPath, 'utf-8'));
76
+ } catch {
77
+ continue;
78
+ }
79
+
80
+ if (status.pipeline_status !== 'running') continue;
81
+ if (!status.pid) continue;
82
+
83
+ if (isPidAlive(status.pid)) {
84
+ count++;
85
+ } else {
86
+ clearStalePid(statusPath, status);
87
+ }
88
+ }
89
+ }
90
+
91
+ return count;
92
+ }