@portel/photon 1.26.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/auto-ui/beam/routes/api-daemon.d.ts +1 -0
  2. package/dist/auto-ui/beam/routes/api-daemon.d.ts.map +1 -1
  3. package/dist/auto-ui/beam/routes/api-daemon.js +35 -1
  4. package/dist/auto-ui/beam/routes/api-daemon.js.map +1 -1
  5. package/dist/beam-form.bundle.js +41 -1
  6. package/dist/beam-form.bundle.js.map +2 -2
  7. package/dist/beam.bundle.js +1661 -252
  8. package/dist/beam.bundle.js.map +4 -4
  9. package/dist/cli/commands/daemon.d.ts.map +1 -1
  10. package/dist/cli/commands/daemon.js +157 -0
  11. package/dist/cli/commands/daemon.js.map +1 -1
  12. package/dist/daemon/client.d.ts +1 -0
  13. package/dist/daemon/client.d.ts.map +1 -1
  14. package/dist/daemon/client.js +110 -23
  15. package/dist/daemon/client.js.map +1 -1
  16. package/dist/daemon/in-process-bridge.d.ts +29 -0
  17. package/dist/daemon/in-process-bridge.d.ts.map +1 -0
  18. package/dist/daemon/in-process-bridge.js +26 -0
  19. package/dist/daemon/in-process-bridge.js.map +1 -0
  20. package/dist/daemon/manager.d.ts +103 -1
  21. package/dist/daemon/manager.d.ts.map +1 -1
  22. package/dist/daemon/manager.js +313 -92
  23. package/dist/daemon/manager.js.map +1 -1
  24. package/dist/daemon/protocol.d.ts +1 -1
  25. package/dist/daemon/protocol.d.ts.map +1 -1
  26. package/dist/daemon/protocol.js +1 -0
  27. package/dist/daemon/protocol.js.map +1 -1
  28. package/dist/daemon/server.js +832 -37
  29. package/dist/daemon/server.js.map +1 -1
  30. package/dist/loader.d.ts.map +1 -1
  31. package/dist/loader.js +11 -0
  32. package/dist/loader.js.map +1 -1
  33. package/package.json +1 -1
  34. package/templates/cloudflare/worker.ts.template +94 -22
@@ -14,6 +14,7 @@ import * as fs from 'fs';
14
14
  import * as path from 'path';
15
15
  import * as os from 'os';
16
16
  import * as crypto from 'crypto';
17
+ import { spawnSync } from 'child_process';
17
18
  import { SessionManager } from './session-manager.js';
18
19
  import { transferHotReloadState } from './hot-reload-state.js';
19
20
  import { resolveWithGlobalFallback } from './session-resolver.js';
@@ -47,10 +48,24 @@ const pidFile = path.join(path.dirname(socketPath), 'daemon.pid');
47
48
  const ownerFile = getOwnerFilePath(socketPath);
48
49
  let daemonOwnershipConfirmed = false;
49
50
  async function isSocketResponsive(target) {
50
- if (process.platform === 'win32' || !fs.existsSync(target))
51
+ // Windows named pipes have no filesystem entry; skip the FS gate on
52
+ // win32 and let net.createConnection probe the pipe directly. The
53
+ // 'error' handler below resolves false on failure; the try/catch
54
+ // wrapper guards against sync throws.
55
+ const isPipe = process.platform === 'win32' && target.startsWith('\\\\.\\pipe\\');
56
+ if (!isPipe && !fs.existsSync(target))
51
57
  return false;
52
58
  return new Promise((resolve) => {
53
- const client = net.createConnection(target);
59
+ let client;
60
+ try {
61
+ // Bun can throw synchronously on a missing/unreachable unix socket
62
+ // before the 'error' listener attaches — TOCTOU vs. existsSync above.
63
+ client = net.createConnection(target);
64
+ }
65
+ catch {
66
+ resolve(false);
67
+ return;
68
+ }
54
69
  const timer = setTimeout(() => {
55
70
  client.destroy();
56
71
  resolve(false);
@@ -437,6 +452,19 @@ staleMapCleanupInterval.unref();
437
452
  */
438
453
  const scheduledJobs = new Map();
439
454
  const jobTimers = new Map();
455
+ /**
456
+ * Per-job count of consecutive failures whose error message points at a
457
+ * missing photon source file (ENOENT against a *.photon.ts path). When
458
+ * the count exceeds AUTO_SUPPRESS_THRESHOLD, the schedule is suppressed
459
+ * and unscheduled — see runJob's catch block. Reset on any successful
460
+ * run or non-ENOENT failure.
461
+ *
462
+ * Without this, a deleted photon file leaves its scheduled methods
463
+ * firing forever (every minute / hour / etc.) — each run failing with
464
+ * ENOENT, log spam, no recovery, no operator signal beyond the noise.
465
+ */
466
+ const photonFileMissingFailures = new Map();
467
+ const AUTO_SUPPRESS_THRESHOLD = 3;
440
468
  // parseCron moved to ./cron.ts so the boot loader and tests can reuse it.
441
469
  function scheduleJob(job) {
442
470
  const { isValid, nextRun } = parseCron(job.cron);
@@ -471,7 +499,13 @@ async function runJob(jobId) {
471
499
  const job = scheduledJobs.get(jobId);
472
500
  if (!job)
473
501
  return;
474
- const key = compositeKey(job.photonName, job.workingDir);
502
+ // `key` may be recomputed after the legacy-base self-heal below — the
503
+ // initial value reflects the schedule's stored workingDir (often undefined
504
+ // for legacy ScheduleProvider files), and the post-pin value reflects the
505
+ // resolved owning base. trackExecution/untrackExecution must run against
506
+ // the post-pin key so hot-reload drain sees the in-flight execution under
507
+ // the same key that other code paths use for the photon. Codex P3.
508
+ let key = compositeKey(job.photonName, job.workingDir);
475
509
  // Phantom prune: when a ScheduleProvider-sourced job's backing file
476
510
  // has been deleted (e.g. `this.schedule.cancel()` ran the unlink
477
511
  // but the in-memory registration survived daemon restart), stop
@@ -515,9 +549,64 @@ async function runJob(jobId) {
515
549
  }
516
550
  }
517
551
  if (!sessionManager) {
518
- logger.warn('Cannot run job - photon not initialized', { jobId, photon: job.photonName });
519
- scheduleJob(job); // Reschedule anyway
520
- return;
552
+ // Ghost-schedule guard: when the photon source is gone from every known
553
+ // base, the lazy-load can never succeed. Rescheduling here would loop
554
+ // every interval forever. A reinstall in flight may briefly fail this
555
+ // probe — the lost tick is acceptable because photons rebuild their
556
+ // schedules on first invocation.
557
+ const probe = probePhotonSource(job.photonName, job.workingDir);
558
+ if (!probe.resolved) {
559
+ logger.warn('Dropping orphan scheduled job — photon source missing', {
560
+ jobId,
561
+ photon: job.photonName,
562
+ sourceFile: job.sourceFile,
563
+ });
564
+ if (job.sourceFile) {
565
+ try {
566
+ fs.unlinkSync(job.sourceFile);
567
+ }
568
+ catch {
569
+ // File may have been removed by a concurrent cleanup
570
+ }
571
+ }
572
+ unscheduleJob(jobId);
573
+ return;
574
+ }
575
+ // Legacy schedule with no workingDir but the photon lives in a
576
+ // registered non-default base: pin workingDir and retry the lazy-load
577
+ // once. Without this self-heal, the probe says OK but the next
578
+ // `getOrCreateSessionManager` (which only checks workingDir +
579
+ // defaultBase) returns null again and the schedule reschedules forever.
580
+ if (!job.workingDir && probe.ownerBase) {
581
+ job.workingDir = probe.ownerBase;
582
+ // Recompute the execution key against the resolved base so
583
+ // trackExecution/untrackExecution and any consumer that looks up the
584
+ // base-scoped key (notably hot-reload drain) see this run under the
585
+ // same identity as the rest of the photon's lifecycle.
586
+ key = compositeKey(job.photonName, job.workingDir);
587
+ logger.info('Pinned scheduled job to resolved base — retrying lazy-load', {
588
+ jobId,
589
+ photon: job.photonName,
590
+ resolvedBase: probe.ownerBase,
591
+ });
592
+ try {
593
+ sessionManager =
594
+ (await getOrCreateSessionManager(job.photonName, job.photonPath, job.workingDir)) ??
595
+ undefined;
596
+ }
597
+ catch (err) {
598
+ logger.warn('Lazy-load retry after base pin failed', {
599
+ jobId,
600
+ photon: job.photonName,
601
+ error: getErrorMessage(err),
602
+ });
603
+ }
604
+ }
605
+ if (!sessionManager) {
606
+ logger.warn('Cannot run job - photon not initialized', { jobId, photon: job.photonName });
607
+ scheduleJob(job); // Reschedule anyway
608
+ return;
609
+ }
521
610
  }
522
611
  logger.info('Running scheduled job', { jobId, method: job.method, photon: job.photonName });
523
612
  trackExecution(key);
@@ -525,6 +614,7 @@ async function runJob(jobId) {
525
614
  let status = 'success';
526
615
  let errorMessage;
527
616
  let result;
617
+ let suppressedThisRun = false;
528
618
  try {
529
619
  const session = await sessionManager.getOrCreateSession('scheduler', 'scheduler');
530
620
  result = await sessionManager.loader.executeTool(session.instance, job.method, job.args || {});
@@ -542,6 +632,7 @@ async function runJob(jobId) {
542
632
  runCount: job.runCount,
543
633
  });
544
634
  logger.info('Job completed', { jobId, method: job.method, runCount: job.runCount });
635
+ photonFileMissingFailures.delete(jobId);
545
636
  }
546
637
  catch (error) {
547
638
  status = 'error';
@@ -553,6 +644,53 @@ async function runJob(jobId) {
553
644
  method: job.method,
554
645
  error: errorMessage,
555
646
  });
647
+ // Auto-suppress when the photon source file has clearly vanished.
648
+ // Without this, a deleted .photon.ts keeps its scheduled methods
649
+ // firing forever — every fire failing with ENOENT, log spam, no
650
+ // recovery, no operator signal beyond the noise.
651
+ const isPhotonFileMissing = errorMessage.includes('ENOENT') &&
652
+ (errorMessage.includes('.photon.ts') ||
653
+ (job.photonPath != null && errorMessage.includes(job.photonPath)) ||
654
+ (job.sourceFile != null && errorMessage.includes(job.sourceFile)));
655
+ if (isPhotonFileMissing) {
656
+ const count = (photonFileMissingFailures.get(jobId) ?? 0) + 1;
657
+ photonFileMissingFailures.set(jobId, count);
658
+ if (count >= AUTO_SUPPRESS_THRESHOLD) {
659
+ // Cross-check with the authoritative source probe before suppressing —
660
+ // a transient ENOENT during reinstall shouldn't kill the schedule.
661
+ const probe = probePhotonSource(job.photonName, job.workingDir);
662
+ if (!probe.resolved) {
663
+ const suppressBase = path.resolve(job.workingDir ?? probe.ownerBase ?? getDefaultContext().baseDir);
664
+ logger.error('Auto-suppressing schedule: photon source missing for ' +
665
+ `${count} consecutive runs. Re-enable with \`photon ps enable ${job.photonName}:${job.method}\` ` +
666
+ 'after the source file is restored.', {
667
+ jobId,
668
+ photon: job.photonName,
669
+ method: job.method,
670
+ suppressBase,
671
+ });
672
+ try {
673
+ writeSuppressedEntry(suppressBase, job.photonName, job.method);
674
+ }
675
+ catch (writeErr) {
676
+ logger.warn('Failed to persist suppression entry', {
677
+ error: getErrorMessage(writeErr),
678
+ });
679
+ }
680
+ unscheduleJob(jobId);
681
+ suppressedThisRun = true;
682
+ }
683
+ else {
684
+ // Source reappeared between failures — reset counter.
685
+ photonFileMissingFailures.set(jobId, 0);
686
+ }
687
+ }
688
+ }
689
+ else {
690
+ // Different failure class — reset the photon-missing counter so
691
+ // unrelated noise doesn't accumulate toward suppression.
692
+ photonFileMissingFailures.delete(jobId);
693
+ }
556
694
  }
557
695
  finally {
558
696
  untrackExecution(key);
@@ -566,6 +704,8 @@ async function runJob(jobId) {
566
704
  outputPreview: status === 'success' ? previewResult(result) : undefined,
567
705
  }, job.workingDir);
568
706
  }
707
+ if (suppressedThisRun)
708
+ return;
569
709
  scheduleJob(job);
570
710
  }
571
711
  function unscheduleJob(jobId) {
@@ -578,8 +718,52 @@ function unscheduleJob(jobId) {
578
718
  if (existed) {
579
719
  logger.info('Job unscheduled', { jobId });
580
720
  }
721
+ // Forget any auto-suppress counter for this slot — if the same key
722
+ // is later re-scheduled, the count starts fresh.
723
+ photonFileMissingFailures.delete(jobId);
581
724
  return existed;
582
725
  }
726
+ /**
727
+ * Append a suppressed entry to a base's active-schedules file so the
728
+ * daemon won't re-register the schedule at next boot. Idempotent on the
729
+ * (photon, method) pair.
730
+ */
731
+ function writeSuppressedEntry(baseDir, photon, method) {
732
+ const file = readActiveSchedulesFile(baseDir);
733
+ const suppressed = file.suppressed ?? [];
734
+ if (!suppressed.some((s) => s.photon === photon && s.method === method)) {
735
+ suppressed.push({ photon, method, suppressedAt: new Date().toISOString() });
736
+ file.suppressed = suppressed;
737
+ }
738
+ // Drop any active-row that matches — no point keeping it around.
739
+ file.active = file.active.filter((e) => !(e.photon === photon && e.method === method));
740
+ writeActiveSchedulesFile(baseDir, file);
741
+ }
742
+ /**
743
+ * Evict a scheduled job by its raw ID. Used by both the IPC `unschedule`
744
+ * handler and the in-process bridge that the loader uses when it's
745
+ * running inside the daemon. Returns true iff a job was actually removed.
746
+ */
747
+ function evictScheduledJobByRawId(rawJobId) {
748
+ const jobId = asScheduleKey(rawJobId);
749
+ let actualJobId = jobId;
750
+ if (!scheduledJobs.has(jobId)) {
751
+ // The loader hands us a bare UUID-shaped ID; the registry stores
752
+ // it under a `<base>::<photon>:ipc:<uuid>` key, so probe both.
753
+ for (const key of scheduledJobs.keys()) {
754
+ if (key.endsWith(`:ipc:${jobId}`)) {
755
+ actualJobId = key;
756
+ break;
757
+ }
758
+ }
759
+ }
760
+ const job = scheduledJobs.get(actualJobId);
761
+ const removed = unscheduleJob(actualJobId);
762
+ if (removed && job) {
763
+ deletePersistedIpcSchedule(actualJobId, job.photonName);
764
+ }
765
+ return removed;
766
+ }
583
767
  /**
584
768
  * Resolve the canonical schedules dir for a photon under the Option B
585
769
  * contract: {workingDir || default baseDir}/.data/{photonName}/schedules/.
@@ -598,6 +782,131 @@ function unscheduleJob(jobId) {
598
782
  function resolveScheduleDir(photonName, workingDir) {
599
783
  return getPhotonSchedulesDir('', photonName, workingDir || getDefaultContext().baseDir);
600
784
  }
785
+ const PHOTON_SOURCE_EXTENSIONS = ['.photon.ts', '.photon.tsx', '.photon.js'];
786
+ const HOST_DISABLE_MARKER = '.photon-no-host';
787
+ /**
788
+ * A base is "host-disabled" when a `.photon-no-host` marker file exists at
789
+ * its root. The daemon will not load ScheduleProvider files, auto-register
790
+ * `@scheduled` annotations, or wire `@webhook` routes for that base.
791
+ *
792
+ * Manual `photon run` on a photon under that base still works — host mode
793
+ * only suppresses background activation. Used to keep one machine the sole
794
+ * scheduler/runner across a multi-host setup that shares a `~/Projects`
795
+ * tree (e.g. via Syncthing): place the marker at the base root on every
796
+ * host that should be quiet.
797
+ */
798
+ function isHostDisabledBase(basePath) {
799
+ try {
800
+ return fs.existsSync(path.join(basePath, HOST_DISABLE_MARKER));
801
+ }
802
+ catch {
803
+ return false;
804
+ }
805
+ }
806
+ /**
807
+ * Synchronous probe for whether a photon's source file is resolvable.
808
+ * Mirrors the resolution semantics used by `runJob` and the photon-core
809
+ * async `resolvePhotonPath`: namespace-qualified names (`team:foo`) only
810
+ * search `<base>/team/foo.photon.{ts,tsx,js}`; unqualified names search
811
+ * flat files first, then one-level namespace subdirs.
812
+ *
813
+ * Scoping rules:
814
+ * - When `baseHint` is supplied (per-base layout: schedule has a
815
+ * `workingDir`), probe ONLY that base + the default base. This is the
816
+ * resolver runJob actually uses, so a ghost detected here is exactly a
817
+ * schedule whose lazy-load would fail at fire time. Do NOT walk every
818
+ * registered base — a legitimate copy of `claw` at `~/Projects/claw/`
819
+ * must not mask stale claw schedule files left in
820
+ * `~/Projects/kith/.data/claw/schedules/`.
821
+ * - When `baseHint` is undefined (legacy `~/.photon/schedules/<photon>/`
822
+ * layout — no `workingDir` recorded on the task), the schedule could
823
+ * belong to a photon in any registered base. Walk every base in the
824
+ * active registry. This avoids deleting valid legacy ScheduleProvider
825
+ * files for photons that live in a non-default PHOTON_DIR (Codex P2
826
+ * finding).
827
+ *
828
+ * Sync so cron tick handlers and the boot schedule loader can call it
829
+ * without an await.
830
+ */
831
+ function probePhotonSource(photonName, baseHint) {
832
+ // Parse `namespace:name` format the same way photon-core's resolvePath does.
833
+ // Without this, a namespaced photon like `team:foo` would be probed as the
834
+ // literal string `team:foo.photon.ts` (which never exists) and its valid
835
+ // schedule file under `<base>/team/foo.photon.ts` would be unlinked at
836
+ // boot. Codex P2 finding.
837
+ const colonIdx = photonName.indexOf(':');
838
+ let namespace;
839
+ let bareName = photonName;
840
+ if (colonIdx !== -1) {
841
+ namespace = photonName.slice(0, colonIdx);
842
+ bareName = photonName.slice(colonIdx + 1);
843
+ }
844
+ const candidates = [];
845
+ let defaultBase;
846
+ try {
847
+ defaultBase = path.resolve(getDefaultContext().baseDir);
848
+ }
849
+ catch {
850
+ /* default context may be missing in early boot */
851
+ }
852
+ if (baseHint) {
853
+ candidates.push(path.resolve(baseHint));
854
+ if (defaultBase && !candidates.includes(defaultBase))
855
+ candidates.push(defaultBase);
856
+ }
857
+ else {
858
+ // Legacy layout: no owning base recorded. The photon could live in
859
+ // any registered base — walk all of them before declaring missing.
860
+ if (defaultBase)
861
+ candidates.push(defaultBase);
862
+ try {
863
+ for (const b of listActiveBases()) {
864
+ const resolved = path.resolve(b.path);
865
+ if (!candidates.includes(resolved))
866
+ candidates.push(resolved);
867
+ }
868
+ }
869
+ catch {
870
+ /* registry may not be initialized */
871
+ }
872
+ }
873
+ for (const base of candidates) {
874
+ if (namespace) {
875
+ // Namespace-qualified: only `<base>/<namespace>/<name>.photon.{ts,tsx,js}`.
876
+ for (const ext of PHOTON_SOURCE_EXTENSIONS) {
877
+ if (fs.existsSync(path.join(base, namespace, `${bareName}${ext}`))) {
878
+ return { resolved: true, ownerBase: base };
879
+ }
880
+ }
881
+ continue;
882
+ }
883
+ // Unqualified: flat first, then one-level namespace subdirs.
884
+ for (const ext of PHOTON_SOURCE_EXTENSIONS) {
885
+ if (fs.existsSync(path.join(base, `${bareName}${ext}`))) {
886
+ return { resolved: true, ownerBase: base };
887
+ }
888
+ }
889
+ let entries;
890
+ try {
891
+ entries = fs.readdirSync(base, { withFileTypes: true });
892
+ }
893
+ catch {
894
+ continue;
895
+ }
896
+ for (const entry of entries) {
897
+ if (!entry.isDirectory())
898
+ continue;
899
+ if (entry.name.startsWith('.') || entry.name.startsWith('_'))
900
+ continue;
901
+ for (const ext of PHOTON_SOURCE_EXTENSIONS) {
902
+ if (fs.existsSync(path.join(base, entry.name, `${bareName}${ext}`))) {
903
+ return { resolved: true, ownerBase: base };
904
+ }
905
+ }
906
+ }
907
+ }
908
+ return { resolved: false };
909
+ }
601
910
  /**
602
911
  * Root of the legacy schedules tree (pre-Option-B layout).
603
912
  * Honors `PHOTON_SCHEDULES_DIR` only for the one-release deprecation
@@ -729,6 +1038,56 @@ function loadDaemonSchedulesFromDir(schedulesPath, ttlMs, photonNameHint, workin
729
1038
  return loadPersistedSchedulesFromDir(schedulesPath, ttlMs, photonNameHint, workingDirHint, {
730
1039
  alreadyRegistered: (id) => scheduledJobs.has(asScheduleKey(id)),
731
1040
  register: (job) => {
1041
+ // Drop schedules whose photon source is gone before they ever enter the
1042
+ // cron map. Without this, every daemon boot reloads ghost schedules
1043
+ // from `<base>/.data/<photon>/schedules/*.json`; long-cron ghosts then
1044
+ // sit dormant in memory and resurrect on the next restart even after
1045
+ // the runJob orphan probe would have caught them.
1046
+ const probe = probePhotonSource(job.photonName, job.workingDir);
1047
+ if (!probe.resolved) {
1048
+ logger.warn('Dropping persisted schedule — photon source missing', {
1049
+ jobId: job.id,
1050
+ photon: job.photonName,
1051
+ workingDir: job.workingDir,
1052
+ sourceFile: job.sourceFile,
1053
+ });
1054
+ if (job.sourceFile) {
1055
+ try {
1056
+ fs.unlinkSync(job.sourceFile);
1057
+ }
1058
+ catch {
1059
+ // File may have been removed concurrently
1060
+ }
1061
+ }
1062
+ return false;
1063
+ }
1064
+ // Legacy schedules carry no `workingDir`. Pin the resolved base so
1065
+ // runJob's lazy-load (which only checks workingDir + defaultBase, not
1066
+ // every registered base) can find the photon at fire time. Without
1067
+ // this, the probe says "OK" but `getOrCreateSessionManager` returns
1068
+ // null for photons living in non-default bases — back to the
1069
+ // reschedule loop. Codex P2 finding.
1070
+ if (!job.workingDir && probe.ownerBase) {
1071
+ job.workingDir = probe.ownerBase;
1072
+ logger.info('Pinned legacy schedule to resolved base', {
1073
+ jobId: job.id,
1074
+ photon: job.photonName,
1075
+ resolvedBase: probe.ownerBase,
1076
+ });
1077
+ }
1078
+ // Host mode: the per-base loader already gates by isHostDisabledBase
1079
+ // before calling probePhotonSource, but the legacy flat-root path
1080
+ // (~/.photon/schedules/<photon>/*.json) walks ALL bases via probe and
1081
+ // could resolve to a host-disabled base. Drop here so legacy files
1082
+ // can't reanimate a quiet machine.
1083
+ if (job.workingDir && isHostDisabledBase(job.workingDir)) {
1084
+ logger.info('Skipping persisted schedule — owning base is host-disabled', {
1085
+ jobId: job.id,
1086
+ photon: job.photonName,
1087
+ base: job.workingDir,
1088
+ });
1089
+ return false;
1090
+ }
732
1091
  // Seed in-memory lastRun from persisted lastExecutionAt so the post-run
733
1092
  // reschedule path keeps updating the same field instead of starting at 0.
734
1093
  const scheduledJob = job;
@@ -927,9 +1286,18 @@ function loadAllPersistedSchedules() {
927
1286
  // Always include the default base even if the registry hasn't recorded it yet.
928
1287
  const defaultBase = getDefaultContext().baseDir;
929
1288
  if (!bases.some((b) => b.path === path.resolve(defaultBase))) {
930
- scanBaseDataRoot(defaultBase);
1289
+ if (isHostDisabledBase(defaultBase)) {
1290
+ logger.info('Skipping schedule load — host-disabled base', { base: defaultBase });
1291
+ }
1292
+ else {
1293
+ scanBaseDataRoot(defaultBase);
1294
+ }
931
1295
  }
932
1296
  for (const base of bases) {
1297
+ if (isHostDisabledBase(base.path)) {
1298
+ logger.info('Skipping schedule load — host-disabled base', { base: base.path });
1299
+ continue;
1300
+ }
933
1301
  scanBaseDataRoot(base.path);
934
1302
  }
935
1303
  // Legacy location for schedules that predate the per-base layout.
@@ -1123,6 +1491,12 @@ async function discoverProactiveMetadataAtBoot() {
1123
1491
  // Lazy import to keep daemon startup cheap for users with no bases.
1124
1492
  const core = await import('@portel/photon-core');
1125
1493
  for (const basePath of baseCandidates) {
1494
+ if (isHostDisabledBase(basePath)) {
1495
+ logger.info('Skipping proactive metadata discovery — host-disabled base', {
1496
+ base: basePath,
1497
+ });
1498
+ continue;
1499
+ }
1126
1500
  let photons;
1127
1501
  try {
1128
1502
  photons = await core.listPhotonFilesWithNamespace(basePath);
@@ -1189,6 +1563,10 @@ async function discoverProactiveMetadataAtBoot() {
1189
1563
  function watchBaseForProactiveMetadata(basePath, _isDefaultBase) {
1190
1564
  if (baseDirWatchers.has(basePath))
1191
1565
  return;
1566
+ if (isHostDisabledBase(basePath)) {
1567
+ logger.info('Skipping proactive metadata watcher — host-disabled base', { base: basePath });
1568
+ return;
1569
+ }
1192
1570
  try {
1193
1571
  if (!fs.existsSync(basePath))
1194
1572
  return;
@@ -1292,6 +1670,10 @@ function syncActiveSchedulesAtBoot() {
1292
1670
  let registered = 0;
1293
1671
  let missingRefs = 0;
1294
1672
  for (const basePath of bases) {
1673
+ if (isHostDisabledBase(basePath)) {
1674
+ logger.info('Skipping active-schedules sync — host-disabled base', { base: basePath });
1675
+ continue;
1676
+ }
1295
1677
  const file = readActiveSchedulesFile(basePath);
1296
1678
  let dirty = false;
1297
1679
  // One-time migration: seed the active list from the current
@@ -1340,6 +1722,36 @@ function syncActiveSchedulesAtBoot() {
1340
1722
  if (suppressedSet.has(`${entry.photon}:${entry.method}`))
1341
1723
  continue;
1342
1724
  const key = declaredKey(entry.photon, entry.method, basePath);
1725
+ // Manual entry: cron is embedded directly, no @scheduled declaration
1726
+ // required. Re-validate the cron each boot since the file is hand-editable.
1727
+ if (entry.cron) {
1728
+ const { isValid } = parseCron(entry.cron);
1729
+ if (!isValid) {
1730
+ logger.warn('Manual schedule has invalid cron — skipping', {
1731
+ base: basePath,
1732
+ photon: entry.photon,
1733
+ method: entry.method,
1734
+ cron: entry.cron,
1735
+ });
1736
+ continue;
1737
+ }
1738
+ if (scheduledJobs.has(key))
1739
+ continue;
1740
+ const ok = scheduleJob({
1741
+ id: key,
1742
+ method: entry.method,
1743
+ args: {},
1744
+ cron: entry.cron,
1745
+ runCount: 0,
1746
+ createdAt: Date.now(),
1747
+ createdBy: 'manual',
1748
+ photonName: entry.photon,
1749
+ workingDir: basePath,
1750
+ });
1751
+ if (ok)
1752
+ registered++;
1753
+ continue;
1754
+ }
1343
1755
  const decl = declaredSchedules.get(key);
1344
1756
  if (!decl) {
1345
1757
  missingRefs++;
@@ -1353,6 +1765,44 @@ function syncActiveSchedulesAtBoot() {
1353
1765
  }
1354
1766
  if (scheduledJobs.has(key))
1355
1767
  continue;
1768
+ // Boot-time annotation-vs-provider dedup: a photon that uses both
1769
+ // `@scheduled` AND `this.schedule.create()` for the same method ends
1770
+ // up with both a persisted ScheduleProvider file and an annotation
1771
+ // job. The runtime dedup in autoRegisterFromMetadata only catches
1772
+ // this AFTER the photon is loaded into a session — at boot, both
1773
+ // already landed in scheduledJobs from loadAllPersistedSchedules.
1774
+ // The annotation is the source of truth (codebase intent), so drop
1775
+ // the provider sibling and unlink its persisted file. Without this,
1776
+ // a kith-sync method like scheduled_freshness_scan fires every 15
1777
+ // minutes from BOTH timers; field reports show duplicate browser
1778
+ // navigation and double API calls.
1779
+ for (const [staleKey, job] of Array.from(scheduledJobs.entries())) {
1780
+ if (job.photonName !== entry.photon || job.method !== entry.method)
1781
+ continue;
1782
+ if (staleKey === key)
1783
+ continue;
1784
+ if (!staleKey.includes(':sched:'))
1785
+ continue; // only ScheduleProvider keys
1786
+ const jobBase = job.workingDir ? path.resolve(job.workingDir) : undefined;
1787
+ if (jobBase && jobBase !== basePath)
1788
+ continue; // scope to this base
1789
+ logger.info('Dropping ScheduleProvider duplicate of @scheduled method', {
1790
+ photon: entry.photon,
1791
+ method: entry.method,
1792
+ providerJobId: staleKey,
1793
+ annotationJobId: key,
1794
+ sourceFile: job.sourceFile,
1795
+ });
1796
+ if (job.sourceFile) {
1797
+ try {
1798
+ fs.unlinkSync(job.sourceFile);
1799
+ }
1800
+ catch {
1801
+ // File may have been removed concurrently
1802
+ }
1803
+ }
1804
+ unscheduleJob(staleKey);
1805
+ }
1356
1806
  const ok = scheduleJob({
1357
1807
  id: key,
1358
1808
  method: decl.method,
@@ -2000,10 +2450,19 @@ function readActiveSchedulesFile(baseDir) {
2000
2450
  if (parsed && typeof parsed === 'object' && Array.isArray(parsed.active)) {
2001
2451
  return {
2002
2452
  version: 1,
2003
- active: parsed.active.filter((e) => e &&
2453
+ active: parsed.active
2454
+ .filter((e) => e &&
2004
2455
  typeof e.photon === 'string' &&
2005
2456
  typeof e.method === 'string' &&
2006
- typeof e.enabledAt === 'string'),
2457
+ typeof e.enabledAt === 'string')
2458
+ .map((e) => ({
2459
+ photon: e.photon,
2460
+ method: e.method,
2461
+ enabledAt: e.enabledAt,
2462
+ enabledBy: typeof e.enabledBy === 'string' ? e.enabledBy : 'unknown',
2463
+ paused: !!e.paused,
2464
+ cron: typeof e.cron === 'string' ? e.cron : undefined,
2465
+ })),
2007
2466
  suppressed: Array.isArray(parsed.suppressed)
2008
2467
  ? parsed.suppressed.filter((s) => s && typeof s.photon === 'string' && typeof s.method === 'string')
2009
2468
  : undefined,
@@ -2036,6 +2495,17 @@ async function autoRegisterFromMetadata(photonName, manager) {
2036
2495
  if (autoRegistered.has(autoKey))
2037
2496
  return;
2038
2497
  autoRegistered.add(autoKey);
2498
+ // Host mode: skip @scheduled / @webhook auto-registration when this base
2499
+ // has the .photon-no-host marker. Manual `photon run` still works because
2500
+ // it goes through the command handler, not this auto-register path.
2501
+ const baseForHostCheck = manager.loader?.baseDir ?? getDefaultContext().baseDir;
2502
+ if (isHostDisabledBase(baseForHostCheck)) {
2503
+ logger.debug('Skipping auto-register — host-disabled base', {
2504
+ photon: photonName,
2505
+ base: baseForHostCheck,
2506
+ });
2507
+ return;
2508
+ }
2039
2509
  try {
2040
2510
  // Get a session to access the loaded photon's tools
2041
2511
  const session = await manager.getOrCreateSession('__autoregister', 'system');
@@ -2522,6 +2992,31 @@ async function handleRequest(request, socket) {
2522
2992
  suggestion: 'Include photonName in the request payload',
2523
2993
  };
2524
2994
  }
2995
+ // Host mode: refuse to arm a cron when the owning base is host-disabled.
2996
+ // Without this, a manual `photon run` on a host-disabled machine could
2997
+ // call this.schedule.create() and immediately install a timer — silently
2998
+ // defeating the marker. The check uses the request's workingDir (or the
2999
+ // default base when unset), matching the resolution that scheduleJob
3000
+ // would otherwise apply.
3001
+ const scheduleBase = request.workingDir
3002
+ ? path.resolve(request.workingDir)
3003
+ : path.resolve(getDefaultContext().baseDir);
3004
+ if (isHostDisabledBase(scheduleBase)) {
3005
+ logger.info('Refusing schedule create — host-disabled base', {
3006
+ base: scheduleBase,
3007
+ photon: photonName,
3008
+ method: request.method,
3009
+ });
3010
+ return {
3011
+ type: 'result',
3012
+ id: request.id,
3013
+ success: false,
3014
+ data: {
3015
+ scheduled: false,
3016
+ reason: `host-disabled: ${scheduleBase} has a .photon-no-host marker`,
3017
+ },
3018
+ };
3019
+ }
2525
3020
  // Generate IPC job ID with workingDir hash to prevent cross-project collisions
2526
3021
  const dirHash = request.workingDir
2527
3022
  ? crypto.createHash('sha256').update(request.workingDir).digest('hex').slice(0, 8)
@@ -2558,28 +3053,13 @@ async function handleRequest(request, socket) {
2558
3053
  if (request.type === 'unschedule') {
2559
3054
  // IPC input: coerce at the boundary. Job IDs shipped over the protocol
2560
3055
  // are already ScheduleKey-shaped (<base>::<photon>:<method>).
2561
- const jobId = asScheduleKey(request.jobId);
2562
- // Try exact match first, then look for IPC-prefixed version
2563
- let actualJobId = jobId;
2564
- if (!scheduledJobs.has(jobId)) {
2565
- // Search for IPC-prefixed job
2566
- for (const key of scheduledJobs.keys()) {
2567
- if (key.endsWith(`:ipc:${jobId}`)) {
2568
- actualJobId = key;
2569
- break;
2570
- }
2571
- }
2572
- }
2573
- const job = scheduledJobs.get(actualJobId);
2574
- const unscheduled = unscheduleJob(actualJobId);
2575
- if (unscheduled && job) {
2576
- deletePersistedIpcSchedule(actualJobId, job.photonName);
2577
- }
3056
+ const rawJobId = request.jobId;
3057
+ const unscheduled = evictScheduledJobByRawId(rawJobId);
2578
3058
  return {
2579
3059
  type: 'result',
2580
3060
  id: request.id,
2581
3061
  success: true,
2582
- data: { unscheduled, jobId: actualJobId },
3062
+ data: { unscheduled, jobId: asScheduleKey(rawJobId) },
2583
3063
  };
2584
3064
  }
2585
3065
  // Handle list jobs (legacy shape — just the active cron jobs)
@@ -2664,6 +3144,112 @@ async function handleRequest(request, socket) {
2664
3144
  data: { active, declared, webhooks, sessions, suppressed },
2665
3145
  };
2666
3146
  }
3147
+ // Add an ad-hoc cron schedule for any public method on a photon. Unlike
3148
+ // `enable_schedule`, this does not require an `@scheduled` JSDoc tag in
3149
+ // source — the cron expression is supplied by the caller and persisted
3150
+ // directly in the active-schedules file. Used by the Pulse UI's
3151
+ // "Add schedule" form.
3152
+ if (request.type === 'add_manual_schedule') {
3153
+ const photon = request.photonName;
3154
+ const method = request.method;
3155
+ const cron = request.cron;
3156
+ if (!photon || !method || !cron) {
3157
+ return {
3158
+ type: 'error',
3159
+ id: request.id,
3160
+ error: '`add_manual_schedule` requires photonName, method, and cron',
3161
+ };
3162
+ }
3163
+ const cronCheck = parseCron(cron);
3164
+ if (!cronCheck.isValid) {
3165
+ return {
3166
+ type: 'error',
3167
+ id: request.id,
3168
+ error: `Invalid cron expression: "${cron}"`,
3169
+ };
3170
+ }
3171
+ const preferredBase = request.workingDir;
3172
+ // Refuse to shadow a `@scheduled` declaration — direct the user to
3173
+ // `enable_schedule` so the source-of-truth stays the JSDoc tag.
3174
+ const declMatches = findDeclarationsFor(photon, method, preferredBase);
3175
+ if (declMatches.length > 0) {
3176
+ return {
3177
+ type: 'error',
3178
+ id: request.id,
3179
+ error: `${photon}:${method} already has a @scheduled declaration. ` +
3180
+ `Use enable/disable to manage it, or remove the @scheduled tag first.`,
3181
+ };
3182
+ }
3183
+ const base = path.resolve(preferredBase ?? probePhotonSource(photon).ownerBase ?? getDefaultContext().baseDir);
3184
+ if (isHostDisabledBase(base)) {
3185
+ return {
3186
+ type: 'error',
3187
+ id: request.id,
3188
+ error: `Cannot add manual schedule for ${photon}:${method} — base ${base} is host-disabled ` +
3189
+ `(remove ${base}/.photon-no-host to allow scheduling).`,
3190
+ };
3191
+ }
3192
+ const file = readActiveSchedulesFile(base);
3193
+ const existing = file.active.find((e) => e.photon === photon && e.method === method);
3194
+ const nowIso = new Date().toISOString();
3195
+ if (existing) {
3196
+ // Update the cron in place — same semantics as resave.
3197
+ existing.cron = cron;
3198
+ existing.paused = false;
3199
+ existing.enabledAt = nowIso;
3200
+ existing.enabledBy = request.source || 'manual';
3201
+ }
3202
+ else {
3203
+ file.active.push({
3204
+ photon,
3205
+ method,
3206
+ cron,
3207
+ enabledAt: nowIso,
3208
+ enabledBy: request.source || 'manual',
3209
+ });
3210
+ }
3211
+ // Clear any matching suppression — manual add is an explicit re-enable.
3212
+ if (file.suppressed) {
3213
+ file.suppressed = file.suppressed.filter((s) => !(s.photon === photon && s.method === method));
3214
+ }
3215
+ writeActiveSchedulesFile(base, file);
3216
+ try {
3217
+ touchBase(base);
3218
+ }
3219
+ catch {
3220
+ /* non-fatal */
3221
+ }
3222
+ const key = declaredKey(photon, method, base);
3223
+ // If a timer for this key was already running (e.g. previous manual entry
3224
+ // with a different cron), unscheduleJob first so scheduleJob arms the new cron.
3225
+ if (scheduledJobs.has(key)) {
3226
+ unscheduleJob(key);
3227
+ }
3228
+ const ok = scheduleJob({
3229
+ id: key,
3230
+ method,
3231
+ args: {},
3232
+ cron,
3233
+ runCount: 0,
3234
+ createdAt: Date.now(),
3235
+ createdBy: 'manual',
3236
+ photonName: photon,
3237
+ workingDir: base,
3238
+ });
3239
+ if (!ok) {
3240
+ return {
3241
+ type: 'error',
3242
+ id: request.id,
3243
+ error: `Failed to schedule ${photon}:${method} — see daemon logs.`,
3244
+ };
3245
+ }
3246
+ return {
3247
+ type: 'result',
3248
+ id: request.id,
3249
+ success: true,
3250
+ data: { photon, method, cron, base, status: 'active' },
3251
+ };
3252
+ }
2667
3253
  // Enroll a declared @scheduled method into the active list.
2668
3254
  if (request.type === 'enable_schedule') {
2669
3255
  const photon = request.photonName;
@@ -2696,6 +3282,17 @@ async function handleRequest(request, socket) {
2696
3282
  }
2697
3283
  const { key, decl } = matches[0];
2698
3284
  const base = path.resolve(decl.workingDir || getDefaultContext().baseDir);
3285
+ // Host mode: refuse to arm a timer when the owning base is host-disabled.
3286
+ // Disable still works (broad sweep) so users can clear stale state from
3287
+ // any machine; only enabling background work is blocked.
3288
+ if (isHostDisabledBase(base)) {
3289
+ return {
3290
+ type: 'error',
3291
+ id: request.id,
3292
+ error: `Cannot enable ${photon}:${method} — base ${base} is host-disabled ` +
3293
+ `(remove ${base}/.photon-no-host to allow scheduling).`,
3294
+ };
3295
+ }
2699
3296
  const file = readActiveSchedulesFile(base);
2700
3297
  const existing = file.active.find((e) => e.photon === photon && e.method === method);
2701
3298
  if (existing) {
@@ -2767,7 +3364,13 @@ async function handleRequest(request, socket) {
2767
3364
  }
2768
3365
  const match = matches[0];
2769
3366
  // Disable tolerates a missing declaration so the caller can clean up
2770
- // orphan active-schedule rows after the source file is deleted.
3367
+ // orphan active-schedule rows after the source file is deleted. When
3368
+ // there's no declaration AND no caller-pinned base, treat the disable
3369
+ // as a hard guarantee and broaden the in-memory sweep + persisted-file
3370
+ // walk across every known base — the user can't be expected to know
3371
+ // which PHOTON_DIR seeded a ghost schedule.
3372
+ const orphan = !match;
3373
+ const broaden = orphan && !preferredBase;
2771
3374
  const base = path.resolve(match?.decl.workingDir ?? preferredBase ?? getDefaultContext().baseDir);
2772
3375
  const file = readActiveSchedulesFile(base);
2773
3376
  const before = file.active.length;
@@ -2792,13 +3395,15 @@ async function handleRequest(request, socket) {
2792
3395
  // flagged.
2793
3396
  const key = match?.key ?? declaredKey(photon, method, base);
2794
3397
  unscheduleJob(key);
3398
+ let filesRemoved = 0;
2795
3399
  // Defensive sweep: during upgrade transitions a daemon may still hold
2796
3400
  // timers registered under the pre-base-scoping `${photon}:${method}` key
2797
3401
  // format. Report-success-but-keep-firing is worse than a noisy sweep, so
2798
- // also drop any job matching this request but only when the job is
2799
- // scoped to THIS base (or the legacy "no base" form). Without this check,
2800
- // disabling foo:bar under base X would also stop foo:bar under base Y,
2801
- // silently breaking the other PHOTON_DIR's timer.
3402
+ // also drop any job matching this request. Restrict to `base` unless this
3403
+ // is an orphan disable without a preferred base see comment above.
3404
+ // Persisted ScheduleProvider files are unlinked here so they don't
3405
+ // resurrect on the next daemon restart; without this, disable was a
3406
+ // memory-only operation and the boot loader would replay the ghost.
2802
3407
  for (const staleKey of Array.from(scheduledJobs.keys())) {
2803
3408
  const job = scheduledJobs.get(staleKey);
2804
3409
  if (!job)
@@ -2807,17 +3412,78 @@ async function handleRequest(request, socket) {
2807
3412
  continue;
2808
3413
  if (staleKey === key)
2809
3414
  continue; // already handled above
2810
- // Only sweep legacy keys (no base prefix) and keys scoped to `base`.
2811
3415
  const jobBase = job.workingDir ? path.resolve(job.workingDir) : undefined;
2812
- if (jobBase && jobBase !== base)
3416
+ if (!broaden && jobBase && jobBase !== base)
2813
3417
  continue;
3418
+ if (job.sourceFile) {
3419
+ try {
3420
+ fs.unlinkSync(job.sourceFile);
3421
+ filesRemoved++;
3422
+ }
3423
+ catch {
3424
+ // File may have been removed concurrently
3425
+ }
3426
+ }
2814
3427
  unscheduleJob(staleKey);
2815
3428
  }
3429
+ // Even when no in-memory job matched, scan persisted ScheduleProvider
3430
+ // files. Useful for ghost schedules whose timer was already dropped
3431
+ // (e.g. by the runJob orphan check) but whose JSON file would
3432
+ // otherwise revive the ghost on the next daemon restart.
3433
+ if (orphan) {
3434
+ const basesToScan = new Set();
3435
+ basesToScan.add(base);
3436
+ if (broaden) {
3437
+ try {
3438
+ basesToScan.add(path.resolve(getDefaultContext().baseDir));
3439
+ }
3440
+ catch {
3441
+ /* ignore */
3442
+ }
3443
+ try {
3444
+ for (const b of listActiveBases())
3445
+ basesToScan.add(path.resolve(b.path));
3446
+ }
3447
+ catch {
3448
+ /* ignore */
3449
+ }
3450
+ }
3451
+ for (const baseDir of basesToScan) {
3452
+ const dir = resolveScheduleDir(photon, baseDir);
3453
+ let entries;
3454
+ try {
3455
+ entries = fs.readdirSync(dir).filter((f) => f.endsWith('.json'));
3456
+ }
3457
+ catch {
3458
+ continue;
3459
+ }
3460
+ for (const f of entries) {
3461
+ const fp = path.join(dir, f);
3462
+ try {
3463
+ const task = JSON.parse(fs.readFileSync(fp, 'utf-8'));
3464
+ if (task.method === method) {
3465
+ fs.unlinkSync(fp);
3466
+ filesRemoved++;
3467
+ }
3468
+ }
3469
+ catch {
3470
+ // Ignore malformed file or unlink race
3471
+ }
3472
+ }
3473
+ }
3474
+ }
2816
3475
  return {
2817
3476
  type: 'result',
2818
3477
  id: request.id,
2819
3478
  success: true,
2820
- data: { photon, method, base, removed: removed > 0, status: 'disabled' },
3479
+ data: {
3480
+ photon,
3481
+ method,
3482
+ base,
3483
+ removed: removed > 0 || filesRemoved > 0,
3484
+ filesRemoved,
3485
+ status: 'disabled',
3486
+ },
2821
3487
  };
2822
3488
  }
2823
3489
  // Pause: keep the enrollment record but cancel the timer.
@@ -2847,6 +3513,17 @@ async function handleRequest(request, socket) {
2847
3513
  const decl = match?.decl;
2848
3514
  const base = path.resolve(decl?.workingDir ?? preferredBase ?? getDefaultContext().baseDir);
2849
3515
  const key = match?.key ?? declaredKey(photon, method, base);
3516
+ // Host mode: pause is allowed (it stops background work, consistent with
3517
+ // the marker's intent). Resume is rejected because it would arm a timer
3518
+ // on a quiet machine.
3519
+ if (!pause && isHostDisabledBase(base)) {
3520
+ return {
3521
+ type: 'error',
3522
+ id: request.id,
3523
+ error: `Cannot resume ${photon}:${method} — base ${base} is host-disabled ` +
3524
+ `(remove ${base}/.photon-no-host to allow scheduling).`,
3525
+ };
3526
+ }
2850
3527
  const file = readActiveSchedulesFile(base);
2851
3528
  const entry = file.active.find((e) => e.photon === photon && e.method === method);
2852
3529
  if (!entry) {
@@ -4398,6 +5075,17 @@ function startupWatchPhotons() {
4398
5075
  const photonDir = getDefaultContext().baseDir;
4399
5076
  if (!fs.existsSync(photonDir))
4400
5077
  return;
5078
+ // Host mode: when the default base is host-disabled, skip the file
5079
+ // watcher, the eager onInitialize loader, and the directory watcher
5080
+ // entirely. They all activate background work that the marker is
5081
+ // supposed to suppress. The photon-paths map stays empty for this
5082
+ // base; manual `photon run` populates entries on demand.
5083
+ if (isHostDisabledBase(photonDir)) {
5084
+ logger.info('Skipping startupWatchPhotons — host-disabled default base', {
5085
+ base: photonDir,
5086
+ });
5087
+ return;
5088
+ }
4401
5089
  let entries;
4402
5090
  try {
4403
5091
  entries = fs.readdirSync(photonDir, { withFileTypes: true });
@@ -4516,7 +5204,10 @@ function startupWatchPhotons() {
4516
5204
  }
4517
5205
  function startServer() {
4518
5206
  const server = net.createServer((socket) => {
4519
- logger.info('Client connected');
5207
+ // Demoted from info to debug: scheduler healthchecks open a fresh
5208
+ // connection every minute. At info level these two lines pair up to
5209
+ // dominate the daemon log (5.6M lines / 558 MB seen in the wild).
5210
+ logger.debug('Client connected');
4520
5211
  connectedSockets.add(socket);
4521
5212
  let buffer = '';
4522
5213
  socket.on('data', (chunk) => {
@@ -4548,7 +5239,7 @@ function startServer() {
4548
5239
  })();
4549
5240
  });
4550
5241
  socket.on('end', () => {
4551
- logger.info('Client disconnected');
5242
+ logger.debug('Client disconnected');
4552
5243
  connectedSockets.delete(socket);
4553
5244
  cleanupSocketSubscriptions(socket);
4554
5245
  });
@@ -4590,6 +5281,60 @@ function startServer() {
4590
5281
  });
4591
5282
  });
4592
5283
  }
5284
+ /**
5285
+ * Scan the OS process table for any other process whose argv looks like
5286
+ * a Photon daemon for THIS exact socket path. Returns their PIDs. Used to
5287
+ * defend against the multi-daemon-on-one-socket failure mode where a
5288
+ * previous daemon survived a stop/start race (or was launched bypassing
5289
+ * the DaemonManager entirely, e.g. directly via `node server.js` from a
5290
+ * test or worktree). The owner-record check only spots siblings the
5291
+ * previous daemon successfully recorded; an argv scan finds the rest
5292
+ * regardless of which runtime (node/bun/deno) launched them.
5293
+ *
5294
+ * Match shape: argv contains `daemon/server.js` AND the literal socket
5295
+ * path. Self is filtered. Windows is skipped (named-pipe IPC has its own
5296
+ * exclusivity guarantees and `ps` isn't available).
5297
+ */
5298
+ function findImposterDaemonPids() {
5299
+ if (process.platform === 'win32')
5300
+ return [];
5301
+ const pids = [];
5302
+ try {
5303
+ const result = spawnSync('ps', ['-ax', '-o', 'pid=,args='], {
5304
+ encoding: 'utf-8',
5305
+ timeout: 3000,
5306
+ });
5307
+ if (result.status !== 0)
5308
+ return [];
5309
+ const myPid = process.pid;
5310
+ for (const rawLine of (result.stdout || '').split('\n')) {
5311
+ const line = rawLine.trim();
5312
+ if (!line)
5313
+ continue;
5314
+ const m = line.match(/^(\d+)\s+(.*)$/);
5315
+ if (!m)
5316
+ continue;
5317
+ const pid = parseInt(m[1], 10);
5318
+ const cmd = m[2];
5319
+ if (!Number.isFinite(pid) || pid === myPid)
5320
+ continue;
5321
+ if (!cmd.includes('daemon/server.js'))
5322
+ continue;
5323
+ // Match the socket path as a whitespace-bounded token so a daemon
5324
+ // for `/tmp/foo.sock` doesn't accidentally match `/tmp/foo.sock.bak`.
5325
+ if (!new RegExp(`(^|\\s)${escapeRegExp(socketPath)}(\\s|$)`).test(cmd))
5326
+ continue;
5327
+ pids.push(pid);
5328
+ }
5329
+ }
5330
+ catch (err) {
5331
+ logger.warn('Failed to scan for imposter daemons', { error: getErrorMessage(err) });
5332
+ }
5333
+ return pids;
5334
+ }
5335
+ function escapeRegExp(s) {
5336
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
5337
+ }
4593
5338
  async function claimExclusiveOwnership() {
4594
5339
  const owner = readOwnerRecord(ownerFile);
4595
5340
  if (owner && owner.socketPath === socketPath && owner.pid !== process.pid) {
@@ -4626,6 +5371,46 @@ async function claimExclusiveOwnership() {
4626
5371
  }
4627
5372
  removeOwnerRecord(ownerFile);
4628
5373
  }
5374
+ // Belt-and-suspenders: even after the owner-record path, scan the OS
5375
+ // process table for any other process running `daemon/server.js` against
5376
+ // this same socket. Catches imposters that bypassed DaemonManager (direct
5377
+ // `node server.js` invocations from tests/worktrees, leftover daemons whose
5378
+ // owner record was wiped, daemons started by a different runtime than the
5379
+ // one currently bound).
5380
+ const imposters = findImposterDaemonPids();
5381
+ if (imposters.length > 0) {
5382
+ logger.warn('Imposter daemon(s) detected via argv scan', {
5383
+ socketPath,
5384
+ currentPid: process.pid,
5385
+ imposterPids: imposters,
5386
+ });
5387
+ for (const pid of imposters) {
5388
+ try {
5389
+ process.kill(pid, 'SIGTERM');
5390
+ }
5391
+ catch {
5392
+ // Already gone
5393
+ }
5394
+ }
5395
+ // Wait for graceful exit, then escalate to SIGKILL on holdouts.
5396
+ const deadline = Date.now() + 5000;
5397
+ while (Date.now() < deadline) {
5398
+ if (imposters.every((pid) => !isPidAlive(pid)))
5399
+ break;
5400
+ await new Promise((r) => setTimeout(r, 100));
5401
+ }
5402
+ for (const pid of imposters) {
5403
+ if (!isPidAlive(pid))
5404
+ continue;
5405
+ logger.warn('Imposter daemon ignored SIGTERM, escalating to SIGKILL', { pid });
5406
+ try {
5407
+ process.kill(pid, 'SIGKILL');
5408
+ }
5409
+ catch {
5410
+ /* ignore */
5411
+ }
5412
+ }
5413
+ }
4629
5414
  if (process.platform !== 'win32' && fs.existsSync(socketPath)) {
4630
5415
  const responsive = await isSocketResponsive(socketPath);
4631
5416
  if (!responsive) {
@@ -4779,6 +5564,16 @@ function shutdown() {
4779
5564
  // Main execution
4780
5565
  void (async () => {
4781
5566
  await claimExclusiveOwnership();
5567
+ // Register in-process adapters BEFORE the loader can be invoked, so
5568
+ // any photon code path that goes through schedule.cancel() while
5569
+ // running inside the daemon evicts directly instead of round-tripping
5570
+ // through our own Unix socket (which fails during recovery windows).
5571
+ const { registerInProcessAdapters } = await import('./in-process-bridge.js');
5572
+ registerInProcessAdapters({
5573
+ unscheduleJob: async (_photonName, jobId) => {
5574
+ return evictScheduledJobByRawId(jobId);
5575
+ },
5576
+ });
4782
5577
  startupWatchPhotons();
4783
5578
  startServer();
4784
5579
  migrateLegacyIpcSchedules();