Skip to content

feat(scheduling): negotiation loop Slice A — propose-and-confirm displacement#120

Merged
nfeuer merged 2 commits into
mainfrom
claude/scheduling-negotiation-design
Jun 13, 2026
Merged

feat(scheduling): negotiation loop Slice A — propose-and-confirm displacement#120
nfeuer merged 2 commits into
mainfrom
claude/scheduling-negotiation-design

Conversation

@nfeuer

@nfeuer nfeuer commented Jun 13, 2026

Copy link
Copy Markdown
Owner

What & why

When a hard-deadline task can't fit before its deadline, the scheduler today just parks it in needs_scheduling. This adds Slice A of the scheduling negotiation design: Donna instead proposes displacing the minimum-cost set of movable Donna-managed events so the task fits — and asks the user to Accept / Decline / Pick-time via Discord. No silent moves.

Design: docs/superpowers/specs/2026-06-12-scheduling-negotiation-design.md. Realizes the needs_scheduling rearrange loop left as future work in docs/domain/scheduling.md; calendar-write serialization per spec_v3.md §3.7.1. Spec drift logged as NEG-A in docs/superpowers/specs/followups.md (the §6.1.2 silent-auto-move conflict tables get rewritten when auto-apply ships in a later slice).

Safety invariants (reviewed line-by-line)

  • User events are never moved. Only donna_managed events on the personal write calendar are eligible; user-created events and anything on a read-only calendar are unconditionally immovable — no override knob (_movable, §1.3).
  • A hard deadline is never silently violated. The negotiator rejects any slot ending past the deadline; a victim that can't re-place before its own deadline makes the arrangement infeasible. Infeasible → surface options, never apply.
  • Termination is structural (depth-1). Displaced tasks re-place into genuinely free slots via find_next_slot, so a moved task can never re-displace anything — no recursion.
  • Serialized + fail-closed. Everything runs under Scheduler._lock; a calendar-read failure aborts placement (CalendarReadError) rather than booking blind. Accept re-validates under the lock (re-reads busy, re-checks each move + T's slot; re-negotiates once on drift and applies only if ≤ the approved cost, else re-proposes).
  • find_next_slot behavior unchanged — the window-valid candidate generator was extracted (_iter_window_valid_slots) and find_next_slot is now "first yielded slot with zero overlaps"; the 22 existing scheduler tests pass as-is.

Scope

Slice A is propose-and-confirm only. Deferred to Slices B/C (extension points left in place): cascade_shift, the overrun detector, multi-displacement (>1 victim chains), and config-gated silent auto_apply. Config lives under calendar.yaml negotiation: with conservative defaults (OD-1…OD-6).

Changes

  • scheduling/scheduler.py: _iter_window_valid_slots refactor; negotiate_placement, _movable, _auto_moves_today, cost fns; negotiate_and_apply + _apply (under-lock re-validation).
  • negotiation_proposals table — Alembic f5a6b7c8d9e0 (down_revision 933f74c55f24, single head) + repo methods + NegotiationProposal ORM.
  • config.py + config/calendar.yaml: NegotiationConfig block.
  • integrations/discord_views.py: NegotiationProposalView (Accept/Decline/Pick-time); notifications/service.py: NOTIF_RESCHEDULE.
  • scheduling/auto_scheduler.py: NoSlotFoundError hook + §2 dispatch matrix (PROPOSED → Discord view, IMPOSSIBLE → never-silent options, CalendarReadError → fallback alert).

Verification

  • 78 passed on the slice command (test_scheduler.py + scheduling/ + test_auto_scheduler.py + persistence) — 27 new negotiation tests.
  • ruff clean; mypy clean; Alembic up/down/up clean, single head.

https://claude.ai/code/session_01ChAGsS8vJGrz44ojrASLYs


Generated by Claude Code

claude added 2 commits June 12, 2026 19:11
Design-ready architecture for the deferred constraint-aware negotiation/rearrange
loop from the Scheduling Fable critique. When a hard-deadline task can't fit before
its deadline, instead of stranding in needs_scheduling, Scheduler.negotiate_placement
displaces the minimum-cost set of MOVABLE Donna-managed events (lowest-priority,
most-flexible) and re-places them into free slots; a cascade_shift primitive handles
overruns. Two non-negotiable invariants: never move user-created events; never
silently violate a hard deadline. Termination is structural (displaced tasks re-place
into FREE slots → cannot re-displace). Ships propose-and-confirm by default (honors
the 2026-06-05 committed-item-confirmation invariant); auto-apply is config-gated.

Verified against code: needs_scheduling->scheduled (alternative_or_rearrange_accepted)
+ needs_scheduling->backlog transitions already exist in task_states.yaml (no state
machine change needed); CalendarEvent carries donna_managed + donna_task_id. Builds on
the S1 bundle's Scheduler._lock, fail-closed _gather_busy, tz-aware find_next_slot.

Includes: displacement cost function, failure->recovery matrix, config contract,
3-slice phasing (single-displacement confirm-only -> multi+auto-apply -> cascade),
test plan, and 6 open owner decisions. No code yet; pending owner decisions.
…lacement

Implements Slice A of the scheduling negotiation design (Plan 2): when a
hard-deadline task can't fit before its deadline, instead of just parking it in
needs_scheduling, propose displacing the minimum-cost set of MOVABLE Donna-managed
events so it fits — propose-and-confirm only (no silent auto-apply).

- _iter_window_valid_slots refactor: extracted the window-valid candidate generator
  from find_next_slot; find_next_slot is now 'first yielded slot with zero overlaps'
  (behavior unchanged — the 22 existing scheduler tests pass as-is).
- Scheduler.negotiate_placement: fail-closed busy union; movable-blocker scan; cost-
  ranked candidates; victims re-place via find_next_slot into FREE slots (structural
  depth-1 termination — a displaced task can never re-displace). Hard-deadline guard:
  rejects any slot ending past the deadline, so a hard deadline is never silently
  blown (for T and, via find_next_slot's own clamp, for each victim).
- Movability filter: user (non-donna_managed) events and read-only-calendar events
  are UNCONDITIONALLY immovable (no override); victim must be scheduled, lower
  priority (conservative OD-1 default), past min_lead, under the per-day auto-move cap.
- negotiate_and_apply (under Scheduler._lock): Slice A always persists the proposal +
  returns PROPOSED. _apply (accept path) re-validates under the lock (re-reads busy,
  verifies move.old/new/T-slot; re-negotiates once on drift, applies only if <= the
  approved cost). Moves before T's create; victims stay scheduled, reschedule_count+1;
  T uses the existing needs_scheduling->scheduled transition (no state-machine change).
- negotiation_proposals table (alembic f5a6b7c8d9e0) + repo + ORM; NegotiationConfig +
  calendar.yaml block (conservative defaults); NegotiationProposalView (Accept/Decline/
  Pick-time) + NOTIF_RESCHEDULE; auto_scheduler NoSlotFoundError hook + §2 dispatch matrix.

Deferred to Slices B/C (extension points left): cascade_shift, overrun detector,
silent auto_apply, multi-displacement. Spec drift logged as NEG-A in followups.md
(§6.1.2 auto-move tables to be rewritten when auto-apply ships). Design + verification:
docs/superpowers/specs/2026-06-12-scheduling-negotiation-design.md; domain doc updated.

Reviewed the safety-critical pieces (movability filter, structural termination, fail-
closed, under-lock re-validation). Tests: 78 passed (incl. 27 new negotiation tests);
ruff + mypy clean; migration up/down/up clean.
@nfeuer nfeuer merged commit 1ce5226 into main Jun 13, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants