---
id: 11858
title: 'Sub 15: Extract DreamCoordinatorService — getDueTask only, due-trigger selection only'
state: CLOSED
labels:
  - enhancement
  - ai
  - refactoring
  - architecture
assignees:
  - neo-opus-ada
createdAt: '2026-05-23T19:29:25Z'
updatedAt: '2026-05-23T20:37:17Z'
githubUrl: 'https://github.com/neomjs/neo/issues/11858'
author: neo-opus-ada
commentsCount: 0
parentIssue: 11831
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking:
  - '[ ] 11862 Sub 18: Orchestrator generic registry wire-up + CadenceEngine pure-trigger-builder correction + collector/pickNextCandidate (Round-2 closer)'
closedAt: '2026-05-23T20:37:17Z'
---
# Sub 15: Extract DreamCoordinatorService — getDueTask only, due-trigger selection only

## Parent Epic

#11831 — Sub 15 of Epic. First of the 3 coordinator extractions in Discussion #11857 Cycle-3.5 B-prime convergence.

## Premise

Per `v13-path.md:117` D3.1 boundary: *"per-task coordinators decide 'what work is due'; Orchestrator wires; ProcessSupervisor executes."* `DreamService` currently has its scheduling logic interleaved in Orchestrator's `scheduleDreamCycle()` (~30 LOC inside the 942-LOC monolith). Pre-existing M4-shape coordinators (`SummarizationCoordinatorService`, `BackupCoordinatorService`, `PrimaryRepoSyncService`) own their due-trigger selection via `getDueTask({...})`. DreamService should match.

## Prescription

Create `ai/daemons/orchestrator/services/DreamCoordinatorService.mjs` with a single public surface:

```js
getDueTask({state, now, dreamIntervalMs, ...}) → trigger | null
```

Matches `SummarizationCoordinatorService.getDueTask({db, state, now, summarySweepIntervalMs, ...})` precedent shape (see `ai/daemons/orchestrator/services/SummarizationCoordinatorService.mjs`).

**Strict boundary:**
- Coordinator owns due-trigger selection ONLY
- `DreamService.processUndigestedSessions()` (and other execution-side methods) stay in `DreamService` — coordinator does NOT absorb them
- No state writes / no `markStarted`/`markCompleted`/`recordTaskOutcome` calls — those stay with Orchestrator + ProcessSupervisorService

## Acceptance Criteria

1. `ai/daemons/orchestrator/services/DreamCoordinatorService.mjs` ships with `getDueTask({...})` only
2. Pure-function unit tests for `getDueTask({...})` with synthetic state (no Orchestrator instance needed)
3. `DreamService` retains `processUndigestedSessions()` + all execution-side methods unchanged
4. Orchestrator's `scheduleDreamCycle()` body is NOT modified yet (Sub 18 wires the new coordinator into the registry); this Sub only ships the extracted service
5. No new env vars / config keys / behavior changes — pure extraction

## Avoided Traps

- Coordinator MUST NOT absorb `DreamService` processing methods
- No reactive-config-aliased "wrapper" around `DreamService` (Sub-1 anti-pattern at new layer)
- No premature integration into Orchestrator (Sub 18 owns the wire-up)
- Stays poorer than a mini-framework — just a single `getDueTask({...})` pure function

## Depends on

Epic #11831 (parent).

## Unblocks

Sub 18 (wire-up needs all 3 coordinators registered).

## Authority

Discussion #11857 Cycle-3.5 GPT `[GRADUATION_APPROVED]` (DC_kwDODSospM4BA-...); operator architectural ratify in-session.

**Authored by:** [Claude Opus 4.7] (Claude Code)

## Timeline

- 2026-05-23T19:29:26Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-05-23T19:29:26Z @neo-opus-ada added the `enhancement` label
- 2026-05-23T19:29:26Z @neo-opus-ada added the `ai` label
- 2026-05-23T19:29:27Z @neo-opus-ada added the `refactoring` label
- 2026-05-23T19:29:27Z @neo-opus-ada added the `architecture` label
- 2026-05-23T19:30:58Z @neo-opus-ada added parent issue #11831
- 2026-05-23T19:31:04Z @neo-opus-ada marked this issue as blocking #11862
- 2026-05-23T19:31:17Z @neo-opus-ada cross-referenced by PR #11856
- 2026-05-23T19:31:20Z @neo-opus-ada cross-referenced by #11854
- 2026-05-23T19:34:17Z @neo-opus-ada cross-referenced by PR #11863
- 2026-05-23T19:49:57Z @neo-opus-ada referenced in commit `79aa419` - "fix(agentos): switch DreamCoordinatorService to gatekeep pattern (#11858)

Same anti-pattern operator caught earlier today on Env (Sub 1 review):
class extending Base + singleton: true + Neo.setupClass for a service that
uses ZERO Neo features — no reactive configs, no lifecycle hooks, no
construct/apply/createId, no className/singleton semantics actually used.
Just a namespace for one pure-wrapper method around buildDreamTrigger.

Switched to gatekeep pattern per src/core/IdGenerator.mjs + src/util/Env.mjs
precedent: plain object literal + Neo.gatekeep(obj, 'Neo.x.y.Z'). Drops
Base inheritance overhead + setupClass processing + the empty static config.

Consumer surface unchanged (DreamCoordinatorService.getDueTask({...}) works
identically). 8/8 unit tests pass against the new shape.

Precedent debt acknowledged: SummarizationCoordinatorService /
BackupCoordinatorService / PrimaryRepoSyncService all have the same
anti-pattern (Base + singleton: true for stateless utilities). Worth a
follow-up sweep — flagged via spawn_task in this turn for separate cleanup."
- 2026-05-23T20:09:52Z @neo-opus-ada referenced in commit `bb8daa9` - "refactor(agentos): collapse DreamCoordinator's pure-fn/wrapper split — one getDueTask (#11858)

Operator caught the split as ceremony-without-payoff. Honest answer: I
mimicked SummarizationCoordinatorService's pattern (separate buildSummaryTrigger
pure function + getDueTask wrapper) without challenging whether the split
was earned. There it IS earned — SummarizationCoordinator.getDueTask calls
getUnreadSunsetHandoversFn(db) for additional I/O input + attaches an
onSuccess hook that marks handovers as read. The pure function takes 4
inputs across 2 branches; the wrapper does real extra work.

DreamCoordinator's getDueTask just maps state.lastRunAt ?? 0 into the
pure function's arg. No I/O, no side-effect hooks, no extra branches.
The split adds ceremony, not value.

Collapsed to single getDueTask({state, now, dreamIntervalMs}). Spec
collapses 8 → 5 tests (the redundant pure-vs-wrapper duplicates
disappear). Default export still uses Neo.gatekeep so the registry
descriptor shape (coordinator field with .getDueTask method) works
identically."
- 2026-05-23T20:13:30Z @neo-opus-ada referenced in commit `fc54ef2` - "refactor(agentos): DreamCoordinator pure module — drop gatekeep + default export (#11858)

Two issues operator caught on re-read:

1. Stale namespace path. Both the @namespace JSDoc tag and the Neo.gatekeep
   argument referenced `Neo.ai.daemons.services.DreamCoordinatorService`
   (the pre-Sub-8 path). Actual filesystem location is now
   `ai/daemons/orchestrator/services/` → namespace would be
   `Neo.ai.daemons.orchestrator.services.DreamCoordinatorService`. Copied
   stale JSDoc from SummarizationCoord precedent without updating.

2. Double export of the same function. `getDueTask` was exported both as
   a named export AND wrapped in the gatekeep default-export object. One
   function exposed via two import paths = same ceremony-without-payoff
   anti-pattern from the prior commit, just at a different layer.

Deeper question: why register via Neo.gatekeep at all? Env / IdGenerator
use it because Neo.util.Env / Neo.core.IdGenerator are broadly-used
substrate where tooling/debug access via Neo.ns() has real value.
DreamCoordinator is orchestrator-internal — consumers import the function
directly; namespace registration buys nothing.

Final shape: pure module with one named export. No gatekeep. No default.
No @namespace JSDoc. Spec drops the Neo + core/_export bootstrap imports
too (not needed without module-load gatekeep call).

4/4 spec tests pass. Net diff vs prior commit: -36 LOC.

Sub 18's registry will consume via `import {getDueTask as dreamGetDueTask}
from '.../DreamCoordinatorService.mjs'` and register the function on a
descriptor — coordinator-as-object wrapper not needed."
- 2026-05-23T20:21:51Z @neo-opus-ada referenced in commit `85e411f` - "refactor(agentos): rename — not a Service, just a function in scheduling/ (#11858)

Operator: "this is not a service. it is a function." Service/Coordinator
suffixes imply state + methods + lifecycle; this file holds one pure
trigger projection. Files describe what they are.

Renames:
- ai/daemons/orchestrator/services/DreamCoordinatorService.mjs
  -> ai/daemons/orchestrator/scheduling/dream.mjs
- test/.../services/DreamCoordinatorService.spec.mjs
  -> test/.../scheduling/dream.spec.mjs

New folder scheduling/ (sibling to services/) holds per-task due-trigger
projections. services/ keeps things that are genuine services. Example:
SummarizationCoordinatorService calls getUnreadSunsetHandoversFn(db) and
attaches onSuccess hooks; that one EARNED the Service suffix. DreamService
/ SwarmHeartbeatService / GoldenPathSynthesizer stay in services/ as
execution-side responsibilities.

JSDoc + describe title updated. No more Coordinator/Service in this file
or its spec. 4/4 spec tests pass at new location.

Sub 18's registry will consume via:
  import {getDueTask as dreamGetDueTask} from './scheduling/dream.mjs'

Downstream churn flagged: Discussion #11857 body + tickets #11859/11860
still use ...CoordinatorService naming. Will update when filing Sub 16 /
Sub 17 work (same shape: scheduling/<task>.mjs). Sub 19 #11861 stays
Service-named since MaintenanceBackpressureService IS a genuine service
(state + multiple visibly-separate surfaces + lease delegation)."
- 2026-05-23T20:25:17Z @neo-opus-ada referenced in commit `c47c1ba` - "docs(agentos): drop decay-prone JSDoc references in scheduling/dream.mjs (#11858)

Operator: "we e.g. change the referenced file, and then what? history versus
intents."

Removed brittle cross-references that lie when targets shift:
- v13-path.md:117 (line number — lies on any edit above line 117)
- D3.1 boundary (section label — lies on doc restructure)
- DreamService.processUndigestedSessions() (specific method name — lies on rename)
- @see ai/daemons/orchestrator/services/DreamService.mjs (scaffolding the
  reader can infer from file location + function name)

Same anti-pattern as Sub 1's historical-context comments: describes a
moment-in-time architecture state, not the file's own intent. JSDoc
should describe what THIS file IS so it stays true regardless of
neighboring substrate evolution.

Result: JSDoc now describes the function's contract (returns trigger
when interval elapsed since lastRunAt; pure). No external coordinates.
4/4 spec tests still pass.

Lesson for Sub 16/17/18/19: no baked line numbers, no section labels,
no neighboring-file method names in JSDoc prose."
- 2026-05-23T20:26:56Z @neo-opus-ada referenced in commit `48bee63` - "docs(agentos): collapse duplicate JSDoc blocks — one function, one comment (#11858)

Operator: file has ONE function, two comment blocks said the same thing
twice. File-level @summary made sense when this was a class with methods
(class JSDoc + method JSDoc). Now that it's a single pure function, the
file IS the function — one JSDoc above getDueTask covers both.

4/4 spec tests still pass."
- 2026-05-23T20:37:17Z @tobiu closed this issue
- 2026-05-23T20:39:41Z @neo-opus-ada cross-referenced by #11864
- 2026-05-23T20:42:53Z @neo-opus-ada cross-referenced by PR #11865
- 2026-05-23T20:57:22Z @neo-opus-ada cross-referenced by PR #11866
- 2026-05-24T02:38:25Z @neo-opus-ada cross-referenced by PR #11877
- 2026-05-24T23:42:29Z @neo-opus-ada cross-referenced by PR #11941

