Compare commits

...
Author SHA1 Message Date
nessarandClaude Sonnet 5.5 43a3a477d6 test(calendar): pin the clock for the Paris today marker
CI / security (push) Waiting to run
CI / validate (push) In progress
CI / test (push) In progress
CI / benchmark (push) In progress
CI / policy (push) Successful in 9s
CI / build (push) In progress
The test of the today marker read the wall clock, so it only exercised
the Paris/local date mismatch between 22:00 and 24:00 UTC and passed
with or without the fix the rest of the day. Pin the clock to 22:16 UTC,
already the next day in Paris, so a UTC runner fails without the fix at
any time.

Refs #301

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 13:56:23 +02:00
nessarandClaude Sonnet 5.5 3b75bdbe3e test(planning): hold the worker until the preparation cancel arrives
The worker reads its queue only between event-loop turns, so the
calendar preparation could finish before the cancel sent on its second
progress update arrived, and the test received a complete plan. Use the
real-worker harness hold already applied to the checkpoint cancellation
test, and cancel on the first preparation update so the hold is released.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 13:56:23 +02:00
nessarandClaude Sonnet 5.5 0d68dfeaf8 fix(calendar): mark today on the Europe/Paris date
The calendar highlighted today from the browser's local date while the
default planning horizon starts on the Europe/Paris date, so the two
disagreed for a few hours a day (and always, for users in another time
zone). The horizon's first day was then not marked as today. This also
failed the CI test run in UTC containers between 22:00 and 24:00 UTC.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 13:56:23 +02:00
nessarandClaude Opus 5.5 8414c02e10 chore: ignore Claude Code local files
CI / policy (pull_request) Successful in 12s
CI / validate (pull_request) Successful in 27s
CI / build (pull_request) Successful in 23s
CI / security (pull_request) Successful in 1m0s
CI / benchmark (pull_request) Successful in 1m48s
CI / test (pull_request) Successful in 1m54s
CI / images (pull_request) Successful in 55s
CI / required (pull_request) Successful in 1s
CI / policy (push) Successful in 8s
CI / validate (push) Successful in 38s
CI / build (push) Successful in 36s
CI / benchmark (push) Successful in 1m16s
CI / security (push) Successful in 56s
CI / test (push) Successful in 1m54s
CI / images (push) Successful in 52s
CI / required (push) Successful in 2s
.claude is a symlink to .agents/, so Claude Code's personal settings
and temporary worktrees land under .agents/. Ignore those paths while
keeping shared settings and skills trackable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 10:26:33 +02:00
nessar abfbca1d45 test(benchmark): calibrate again after the protocol and judge against the slower value
CI / policy (push) Successful in 7s
CI / validate (push) Successful in 33s
CI / build (push) Successful in 23s
CI / security (push) Successful in 57s
CI / test (push) Successful in 1m49s
CI / benchmark (push) Successful in 1m49s
CI / images (push) Successful in 1m21s
CI / required (push) Successful in 2s
See pull request #308
2026-10-19 10:24:14 +02:00
nessarandClaude Sonnet 5.5 b37101435a test(benchmark): calibrate again after the protocol and judge against the slower value
CI / policy (push) Successful in 8s
CI / validate (push) Successful in 30s
CI / build (push) Successful in 36s
CI / policy (pull_request) Successful in 8s
CI / benchmark (push) Successful in 1m14s
CI / validate (pull_request) Successful in 29s
CI / security (push) Successful in 1m5s
CI / test (push) Successful in 1m54s
CI / build (pull_request) Successful in 23s
CI / security (pull_request) Successful in 58s
CI / images (push) Successful in 56s
CI / required (push) Successful in 1s
CI / benchmark (pull_request) Successful in 1m46s
CI / test (pull_request) Successful in 1m49s
CI / images (pull_request) Successful in 1m16s
CI / required (pull_request) Successful in 2s
The relative gate calibrated once before the scenarios, so load that
started afterwards (the push and pull_request runs of one commit overlap
on the same runner, next to the test, build and image jobs) slowed the
scenarios but not the yardstick. A pull_request run failed `two years`
at 1653.1 ms against a 1648.7 ms budget this way.

The spec now repeats the calibration after the protocol and uses the
slower of the two values. The budgets can only loosen, never tighten,
and the log reprints the table with the budgets actually applied when
they change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 10:11:26 +02:00
nessar 7fc3457b63 perf(planning): plan from the best-ranked obligation solution and remove repeated work
CI / policy (push) Successful in 7s
CI / validate (push) Successful in 33s
CI / build (push) Successful in 36s
CI / benchmark (push) Successful in 1m10s
CI / security (push) Successful in 56s
CI / test (push) Successful in 2m2s
CI / images (push) Successful in 53s
CI / required (push) Successful in 2s
close #302

See pull request #303
2026-10-18 23:47:13 +02:00
7 changed files with 74 additions and 34 deletions

No files matched your search

+4
View File
@@ -46,3 +46,7 @@ Thumbs.db
# Auto-generated i18n files
/src/app/i18n-registry.ts
# Claude Code local files (.claude is a symlink to .agents/)
.agents/settings.local.json
.agents/worktrees/
+4
View File
@@ -24,6 +24,10 @@ Before the protocol, in the same process, the spec times the calibration engine
a871f8f) on the `two years` scenario, best of three, and prints the result. This
bundle never changes, so ratios stay comparable with the recorded history; the
differential check's frozen bundle can be rebuilt independently.
The spec repeats the calibration after the protocol and uses the slower of the two
values, so load that starts during the protocol slows the yardstick too; the second
value can only loosen the budgets. When it does, the log prints it and the summary
table again with the budgets actually applied.
Each scenario's budget is its multiplier times that calibration; the mean
`planningMs` of its two measured repetitions must not exceed it. A scenario
without a multiplier fails too. #286 tightened the multipliers once #302 met
+15 -14
View File
@@ -16,24 +16,25 @@ synchronization, and title/body edits. Manual dispatch runs validation without
publishing images. There is no schedule or deployment job in the preserved
configuration; the Compose files remain manual deployment options.
| Job | Behavior |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `policy` | Tests the policy and publisher with Bun, then validates the checked-out commit, GitFlow, PR title, commits, and stable release version. |
| `validate` | Runs ESLint, Prettier (application, `scripts/ci-*.mjs` and workflow files) and `bun check` type checks of application and test code, all on Bun with the frozen lockfile. |
| `test` | Runs functional coverage with `bun test --isolate` (line coverage only), converts it to HTML and Cobertura, and uploads coverage for seven days even on failure. |
| `benchmark` | Required seven-scenario protocol (one warm-up, two measured repetitions) outside coverage in a container limited to 3 GiB. It first times the frozen engine on `two years` (best of three) and fails any scenario whose mean planning time exceeds its multiplier times that calibration; the 240-second timeout is only a hang guard. |
| `build` | Builds the client-only Angular production bundle (the only check of Angular templates; `bun check` does not replace it) and the Bun static-server bundle, and retains `dist` for seven days. |
| `security` | Runs Semgrep JavaScript/TypeScript SAST and Trivy dependency/secret scans; retains SARIF reports for seven days. Scanner execution must succeed; findings remain reports rather than a new severity gate. The scanners are not project JavaScript, so this job installs no Bun. |
| `images` | Publishes branch/SHA images after required validation, including the benchmark, or promotes an existing SHA image for a stable tag, running the publisher with Bun. Runs only for pushes and same-repository PRs. |
| `required` | Fails on failed, cancelled, or skipped required validation, including the benchmark. Requires image publication when expected; accepts its intentional skip for fork PRs and manual dispatch. It is shell only and needs no Bun. |
| Job | Behavior |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `policy` | Tests the policy and publisher with Bun, then validates the checked-out commit, GitFlow, PR title, commits, and stable release version. |
| `validate` | Runs ESLint, Prettier (application, `scripts/ci-*.mjs` and workflow files) and `bun check` type checks of application and test code, all on Bun with the frozen lockfile. |
| `test` | Runs functional coverage with `bun test --isolate` (line coverage only), converts it to HTML and Cobertura, and uploads coverage for seven days even on failure. |
| `benchmark` | Required seven-scenario protocol (one warm-up, two measured repetitions) outside coverage in a container limited to 3 GiB. It times the frozen engine on `two years` (best of three) before and after the protocol, and fails any scenario whose mean planning time exceeds its multiplier times that calibration; the 240-second timeout is only a hang guard. |
| `build` | Builds the client-only Angular production bundle (the only check of Angular templates; `bun check` does not replace it) and the Bun static-server bundle, and retains `dist` for seven days. |
| `security` | Runs Semgrep JavaScript/TypeScript SAST and Trivy dependency/secret scans; retains SARIF reports for seven days. Scanner execution must succeed; findings remain reports rather than a new severity gate. The scanners are not project JavaScript, so this job installs no Bun. |
| `images` | Publishes branch/SHA images after required validation, including the benchmark, or promotes an existing SHA image for a stable tag, running the publisher with Bun. Runs only for pushes and same-repository PRs. |
| `required` | Fails on failed, cancelled, or skipped required validation, including the benchmark. Requires image publication when expected; accepts its intentional skip for fork PRs and manual dispatch. It is shell only and needs no Bun. |
The benchmark runs on both push and pull_request events: the push run gates image
publication (`images.needs` includes `benchmark`) and the `required` aggregate.
The relative gate absorbs a slower runner and load that lasts through the whole
job, because calibration runs in the same job. It does not absorb load that
starts or stops during the protocol, such as an overlapping push and
pull_request run of the same commit; the 240-second hang guard still bounds
those runs.
job, because calibration runs in the same job. It calibrates before and after the
protocol and judges against the slower value, so it also absorbs load that starts
during the protocol, such as an overlapping push and pull_request run of the same
commit. It does not absorb load that ends before the second calibration; the
240-second hang guard still bounds those runs.
Push and PR runs are separate so PR title and rebase checks run on the PR event.
New runs cancel older runs for the same event/ref. Image jobs serialize per
@@ -3,6 +3,7 @@ import { calculatePlanningAsyncInternal } from '../services/frozen-engine/leave-
import { createPlanningCalculation } from '../services/leave-planner.service';
import {
BENCHMARK_BUDGET_MULTIPLIERS,
type BenchmarkMeasurement,
benchmarkReport,
benchmarkScenarios,
benchmarkSummaryHeader,
@@ -71,23 +72,43 @@ describe('leave planner benchmark protocol', () => {
}
// Calibration engine (frozen at a871f8f) on 'two years', best of three: the runner-speed yardstick.
const calibrationRequest = scenarios.find((item) => item.name === 'two years')!.request;
let calibrationMs = Infinity;
for (let run = 0; run < 3; run++) {
const request = structuredClone(calibrationRequest);
const start = performance.now();
await calculatePlanningAsyncInternal({ ...request, calculationDate: '2024-01-01' });
calibrationMs = Math.min(calibrationMs, performance.now() - start);
}
const calibrate = async () => {
let best = Infinity;
for (let run = 0; run < 3; run++) {
const request = structuredClone(calibrationRequest);
const start = performance.now();
await calculatePlanningAsyncInternal({
...request,
calculationDate: '2024-01-01',
});
best = Math.min(best, performance.now() - start);
}
return best;
};
const calibrationBefore = await calibrate();
// Direct stdout writes show each row as soon as its scenario finishes.
const print = (text: string) => process.stdout.write(`${text}\n`);
print(
`\nCalibration: frozen engine on "two years", best of 3 = ${calibrationMs.toFixed(1)} ms` +
`\nCalibration: frozen engine on "two years", best of 3 = ${calibrationBefore.toFixed(1)} ms` +
'\nBudget = limit x calibration; ratio = mean / calibration.\n',
);
print(benchmarkSummaryHeader());
const measurements = await runLeavePlannerBenchmark(2, (rows) =>
print(benchmarkSummaryRow(rows, calibrationMs)),
);
const scenarioRows: BenchmarkMeasurement[][] = [];
const measurements = await runLeavePlannerBenchmark(2, (rows) => {
scenarioRows.push(rows);
print(benchmarkSummaryRow(rows, calibrationBefore));
});
// Load that starts after the first calibration slows the scenarios but not the yardstick;
// calibrating again and judging against the slower value absorbs it. Never tighter than before.
const calibrationAfter = await calibrate();
const calibrationMs = Math.max(calibrationBefore, calibrationAfter);
if (calibrationMs !== calibrationBefore) {
print(
`\nCalibration after the protocol = ${calibrationAfter.toFixed(1)} ms; budgets use the slower: ${calibrationMs.toFixed(1)} ms\n`,
);
print(benchmarkSummaryHeader());
for (const rows of scenarioRows) print(benchmarkSummaryRow(rows, calibrationMs));
}
for (const [name, placements] of [
['two years', 60],
['three years', 90],
+12
View File
@@ -8,6 +8,7 @@ import {
SelectedDates,
} from './calendar-service';
import { DateRange } from '@angular/material/datepicker';
import { vi } from '../../testing/vitest-compat';
describe('CalendarService', () => {
let service: CalendarService;
@@ -197,6 +198,8 @@ describe('CalendarService', () => {
service.project({ placements: [], horizon });
const day = (key: string) => new Date(`${key}T00:00:00`);
afterEach(() => vi.useRealTimers());
it('projects weekends, holidays and today for the default planning horizon', () => {
const { selections } = project({ start: '2026-01-01', end: '2027-01-01' });
expect(selections.dayTypes(day('2026-05-09'))).toContain(DayType.WEEKEND);
@@ -207,6 +210,15 @@ describe('CalendarService', () => {
);
});
it('marks today on the Europe/Paris date when the runtime date is still the day before', () => {
vi.setSystemTime(new Date('2026-10-17T22:16:00Z'));
const horizon = service.getDefaultPlanningHorizon();
expect(horizon.start).toBe('2026-10-18');
const { selections } = project(horizon);
expect(selections.getClasses(day('2026-10-18'))).toContain(DayType.TODAY);
expect(selections.getClasses(day('2026-10-17'))).not.toContain(DayType.TODAY);
});
it('lists the covered months, including partial boundary months and year changes', () => {
expect(project({ start: '2024-11-20', end: '2025-02-02' }).months).toEqual([
{ monthIndex: 10, year: 2024 },
+4 -6
View File
@@ -85,11 +85,9 @@ export class CalendarService {
const holidays = new Set(
this.getHolidays(first.getFullYear(), last.getFullYear()).map((h) => h.getTime()),
);
const {
date: todayDate,
month: todayMonth,
year: todayYear,
} = this.dateCache.getTodayComponents();
const [todayYear, todayMonth, todayDate] = planningDateInParis(new Date())
.split('-')
.map(Number);
const months: CalendarProjection['months'] = [];
const ranges: SelectedDate[] = [];
const cursor = new Date(first);
@@ -124,7 +122,7 @@ export class CalendarService {
);
if (
day.getDate() === todayDate &&
day.getMonth() === todayMonth &&
day.getMonth() + 1 === todayMonth &&
day.getFullYear() === todayYear
)
ranges.push(new SelectedDate(DayType.TODAY, new DateRange<Date>(day, day)));
@@ -587,8 +587,8 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
);
it('stops during calendar preparation without a plan or an obligation conflict', async () => {
holdForCancelAtStage = 'preparation';
const stages: string[] = [];
let preparationUpdates = 0;
const calculation = createPlanningCalculation({
horizon: { start: '2024-01-01', end: '2027-01-01' },
balances: [],
@@ -596,7 +596,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
declaredDays: [{ date: '2024-01-02', type: 'other' }],
progress: (stage) => {
stages.push(stage);
if (stage === 'preparation' && ++preparationUpdates === 2) calculation.cancel();
if (stage === 'preparation') calculation.cancel();
},
});
const outcome = await calculation.outcome;
@@ -606,7 +606,7 @@ parentPort.on('message', (data) => globalThis.self.onmessage({ data }));
});
if (outcome.kind !== 'PlanningIncomplete') return;
expect(outcome.incumbent).toBeUndefined();
expect(preparationUpdates).toBeGreaterThanOrEqual(2);
expect(stages).toContain('preparation');
expect(stages).not.toContain('obligations');
expect(await calculation.explanationError).toBeUndefined();
expect(heartbeats).toBeGreaterThan(0);