Skip to content

#52: should the daemon route through the shared pass, and does it log? #95

Closed

Description

@kridaydave

Question

AutoOrganizeService.runOrganization (src/extensions/scheduler/auto-organize.service.ts:258) moves files and writes no history entry at all. There is no ctx.history.log(...) call anywhere in that file. A daemon-organized pass is invisible: it moves files, leaves nothing in operations.jsonl, writes no rollback manifest, and cannot be undone.

runOrganizePass() does write a row, but with source: "manual" and no manifestId. The source ticket fixes the label. This ticket asks the harder question: should the daemon route through runOrganizePass() at all, or keep its own private pass and just start logging?

The trade, stated plainly.

AutoOrganizeService.runOrganization is a second implementation of a pass, and it has three features runOrganizePass lacks:

  • min_file_age_minutes filter (auto-organize.service.ts:286)
  • max_files_per_run slice (auto-organize.service.ts:299)
  • a per-directory reentrancy guard, runningDirectories Set (auto-organize.service.ts:265)

It also hardcodes dryRun: false (:317) and calls stateService.setLastRunTime(...) (:338), which runOrganizePass never touches.

Option A: the daemon calls runOrganizePass() per tick, and those three features move into the shared pass. This is what #52 asks for and what the file header at organize-pass.ts:8 already plans. One implementation, one history write path, the daemon's passes become undoable for free.

Option B: keep the daemon's private pass, just add history.log() to it. Smaller diff, no feature migration. Leaves two implementations of a pass forever, and #52 stays unsolved.

Constraints that bind the answer.

  • runOrganizePass constructs its own FileScannerService and OrganizerService with no injection seam (organize-pass.ts:77). The existing tests in tests/unit/services/auto-organize.test.ts use constructor injection on AutoOrganizeService (:38). Option A means either adding a seam to the pass or rewriting those tests to assert on disk state in a sandbox.
  • WatchConfig.rules already carries min_file_age_minutes, max_files_per_run, catchup_mode (src/core/config/loader.ts:28). Moving them into the pass options means they stop being daemon-only.
  • dryRun: false hardcoded at auto-organize.service.ts:317 cannot move into the shared pass as a default, since the pass defaults dryRun to true (organize-pass.ts:69) and that default is deliberate.
  • Catchup logic (shouldRunOnStartup, auto-organize.service.ts:464) reads scheduler-state.json via shouldCatchup. The pass has no state service. Catchup is daemon startup behaviour, not pass behaviour, so it stays in the daemon even under Option A.

What to decide. A or B. If A, say whether the three features become pass options (recommended, they are per-directory tuning that belongs with the directory) or stay daemon-side as a pre-filter over the file list.

Note. #52 is this ticket. The implementation is a follow-up child of this one, created once A or B is chosen, because the two answers produce very different diffs. If B, the thin-loop framing of #52 does not apply at all and the issue's premise is wrong.

Blocks. #52, #31 watch_status (a daemon pass's last-run time is only observable if it records one).

Activity

  1. kridaydave commented on Oct 11, 2026

    @kridaydave
    OwnerAuthor

    Decision

    Option A. The daemon routes through runOrganizePass(). The private pass body in AutoOrganizeService.runOrganization is deleted. One pass implementation, one history write path, and daemon passes become undoable for free.

    Two facts the ticket got wrong

    The daemon already writes a rollback manifest. This ticket says it does not. It does: organizer.organize() creates the manifest itself at src/core/organize/organizer.ts:648, and runOrganization calls that. The manifest exists on disk. Nobody knows its id.

    Both paths discard the manifest id. src/extensions/scheduler/organize-pass.ts:93 destructures the organizer result and never reads manifestId. The history.log() call at :120 does not pass it either. So every --once --apply run leaves an undo manifest on disk that nothing can name.

    That is the real gap, and it is why #102 exists: orphan manifests at 96 a day with no recorded id. Fixing it here makes #42 and #102 honest rather than theoretical.

    The six settled answers

    1. Option A. runOrganization keeps its cron scheduling and its logging. The scan, filter, and organize logic goes away.

    2. Record the manifest id in this ticket. Three lines, and it unblocks undo_history selective undo #42.

    3. Age and size limits are daemon-supplied pass options. New fields on OrganizePassOptions. Defaults live in the daemon: min_file_age_minutes: 60, max_files_per_run: 300. --once gets neither, so an automated tool never churns an entire filesystem in one tick.

      Worth stating why: if the defaults applied to every pass, a file dropped in Downloads would sit untouched for an hour. For a cron sweep that is the point. For a person running --once --apply by hand it is a surprise.

    4. scheduler-state.json survives, daemon-only. The daemon calls setLastRunTime after the pass returns, as it does today. The pass stays pure and never touches scheduler state.

      This settles a fog item on the wayfinder map. A cron setup that calls --once directly writes no scheduler state and never will. That is fine: catchup is daemon startup behaviour, and a machine with no daemon has no startup.

    5. Concurrency allowed across directories, one daemon process. runningDirectories is deleted. node-cron already fires different directories independently, so nothing is lost by removing the guard.

      The failure mode without it: two passes over the same directory plan the same file, one wins, the loser gets a source-missing error. A normal error, already handled by the organizer's error path. Not corruption.

    6. Dry run stays a pass option with the default on. The daemon passes dryRun: false explicitly at its call site.

      Not negotiable, and worth being blunt about the alternative. organize-pass.ts:26 defaults dry run to true so a bare --once plans and stops. Flipping that default means bare --once moves real files, which contradicts ONCE_USAGE at once-cli.ts:100 and the frozen exit contract. One fat finger in a crontab line and someone's home directory reorganizes without them asking.

    Implementation

    src/extensions/scheduler/organize-pass.ts

    • New options: min_file_age_minutes?: number, max_files_per_run?: number.
    • Move filterByAge here from auto-organize.service.ts:363. It is a pure function over a file list, it belongs with the pass.
    • Apply the max_files_per_run slice after the age filter, matching the current order at auto-organize.service.ts:286 then :299.
    • Read manifestId off the organizer result and pass it to history.log().

    src/extensions/scheduler/auto-organize.service.ts

    • runOrganization becomes: validate, call runOrganizePass with { directory, dryRun: false, source: "scheduled", min_file_age_minutes: rules.min_file_age_minutes ?? 60, max_files_per_run: rules.max_files_per_run ?? 300 }, then setLastRunTime.
    • Delete the scan, the age filter, the size slice, the organizer.organize call, and runningDirectories.
    • Every daemon path uses source: "scheduled". Catchup is still a scheduled pass; it fired because a schedule was missed, not because a person asked.

    Defaults belong to the daemon, not the pass. The pass options stay optional with no defaults of their own. --once callers get no limit because they pass no value, and that is the whole mechanism. Do not put 60 in OrganizePassOptions as a default; it would silently apply to every caller.

    Tests that must fail without this change

    • A daemon-driven pass writes a history row with source: "scheduled" and a non-empty manifestId.
    • The recorded manifestId names a manifest that getManifest verifies, and undo_last_operation with that id moves the files back.
    • runOrganizePass returns the manifest id in its result, so a caller can report it.
    • A directory with 400 files and max_files_per_run: 300 moves 300 and reports planned: 300.
    • A file younger than min_file_age_minutes is not moved.
    • --once with no options still moves every file regardless of age and count.
    • A bare --once still writes nothing. Guards the dryRun default.

    Proof

    Drive once --apply --source scheduled through the verify sandbox, then read the history row back and confirm manifestId is set and undo_last_operation accepts it.

    C=.opencode/skills/verify-file-organizer/scripts/control-file-organizer.mjs
    node $C call search_history --source scheduled --json
    node $C call undo_last_operation --json

    Then drive the daemon path with a watch config carrying max_files_per_run and confirm the same row shape.

    Not decided here

    • Whether the reentrancy guard returns if someone runs two daemons. One daemon is the model. Out of scope.
  2. kridaydave commented on Oct 11, 2026

    @kridaydave
    OwnerAuthor

    Status: half shipped

    PR #103 landed one part of this. The other part is still open work.

    Done

    748bd0f fix(scheduler): a pass that moves files now leaves an undo handle on its row.

    runOrganizePass now reads manifestId off the organizer result and records it on the history row (src/extensions/scheduler/organize-pass.ts:128). That was the part that made scheduled passes undoable, and it was the part the ticket got wrong about, so good that it landed first.

    Still to do

    The daemon does not route through the shared pass. AutoOrganizeService.runOrganization still has its own private implementation:

    • runningDirectories reentrancy guard still present (auto-organize.service.ts:34, :265, :271)
    • filterByAge still in the daemon (:286, method at :363)
    • max_files_per_run slice still in the daemon (:298)
    • its own organizer.organize call, no runOrganizePass anywhere in the file

    So there are still two pass implementations. The daemon's writes a history row now, but it goes through its own path rather than the shared one.

    Settled answers that still need implementing

    1. Delete the private pass body. runOrganization keeps its cron scheduling, its logging, and its setLastRunTime. The scan, the age filter, the size slice, and the organizer.organize call go away.
    2. Daemon calls runOrganizePass with { directory, dryRun: false, source: "scheduled", min_file_age_minutes: rules.min_file_age_minutes ?? 60, max_files_per_run: rules.max_files_per_run ?? 300 }.
    3. The two limits become OrganizePassOptions fields with no defaults in the pass. The 60 and 300 defaults live in the daemon. --once passes nothing and therefore gets no limit, which is the entire mechanism that keeps an automated tool from churning a whole filesystem in one tick.
    4. Move filterByAge into organize-pass.ts. Pure function over a file list, belongs with the pass.
    5. Delete runningDirectories. One daemon process, many directories, parallel across directories. node-cron already fires independent schedules.
    6. Dry run: daemon always off, pass default stays on. Not negotiable. organize-pass.ts:26 defaults to true so a bare --once plans and stops; ONCE_USAGE at once-cli.ts:100 promises it and the exit contract is frozen. Flipping that default means bare --once moves real files.
    7. scheduler-state.json stays daemon-only. The daemon calls setLastRunTime after the pass returns, exactly as today. The pass stays pure.

    One correction

    The ticket claimed the daemon writes no rollback manifest. It did, at src/core/organize/organizer.ts:648, because organizer.organize creates it internally. What was true is that every path discarded the id. #103 fixed that.

  3. kridaydave commented on Oct 12, 2026

    @kridaydave
    OwnerAuthor

    Decided and shipped by #109 (merge 77e64ad). Option A: the daemon routes through the shared pass. AutoOrganizeService.runOrganization now calls runOrganizePass, so a cron tick and --once produce the same history row and the same rollback manifest id, and a scheduled pass is undoable. The three daemon-only features moved into the pass options (min_file_age_minutes, max_files_per_run), the reentrancy guard sits on the cron task and covers triggerNow and runMissedSchedules too, dryRun: false stays a daemon-side argument because the pass default is deliberately true, and catchup stays daemon-side. The injection seam question did not come up: the pass kept its own construction and the existing tests still hold.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions