Skip to content

Commit f4fdfef

Browse files
refactor(sync): split sweep candidate ownership
1 parent 0a67032 commit f4fdfef

9 files changed

Lines changed: 180 additions & 124 deletions

File tree

docs/intentional-architecture-rewrite-2026-06-27/decision-log.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ Platform transcript discovery returns `DiscoveredTranscript` facts: app, session
131131

132132
The top-level sync workflow file coordinates the sync verb; it should not be a bucket for all sync helper behavior. Source/quiet parsing, transcript-to-repo candidate shaping, sweep-summary projection, and sync Absorb prompt context each have named files under `src/services/sync/`. `src/services/sync/sync.ts` keeps config activation lookup and lifecycle Absorb handoff because those are workflow decisions of the sync product verb.
133133

134+
The sync sweep file coordinates candidate iteration. It may show the order of eligibility checks, repo locks, ledger reconciliation, transcript snapshot reads, cursor decisions, Absorb enqueue attempts, and summary accounting. It should not own the implementation details for candidate eligibility, internal-Almanac-session lookup, or ready-cursor-to-Absorb enqueue transitions. Those live in `candidate-eligibility.ts`, `internal-sessions.ts`, and `absorb-enqueue.ts`, respectively. Sweep-result files own summary shapes only, not prompt context text.
135+
134136
### Automation scheduler contracts are service-owned; launchd is platform
135137

136138
`src/services/automation/` owns automation product workflows: task selection, interval validation, install/status/uninstall results, legacy migration semantics, and setup cleanup verbs. It talks to an injected `AutomationScheduler` contract from `src/services/automation/scheduler.ts`. `src/platform/automation/scheduler.ts` implements that contract with launchd/plist mechanics: default plist paths, log paths, launch PATH construction, plist writes, launchctl activation/removal/status, legacy capture detection, and XML-to-command-array normalization. CLI/setup/uninstall edges create the launchd scheduler because they are the concrete runtime composition points.

docs/intentional-architecture-rewrite-2026-06-27/status.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ Branch: `codex/intentional-architecture-rewrite`
55

66
## Current State
77

8-
The branch has more than 280 committed rewrite commits past `dev`. The worklog records 232 production slices so far.
8+
The branch has more than 280 committed rewrite commits past `dev`. The worklog records 233 production slices so far.
99

1010
The diff is broad: more than 490 files changed, with tens of thousands of lines reshaped.
1111

@@ -56,6 +56,7 @@ This is no longer a small cleanup branch. It is a real ownership rewrite.
5656
- Moved concrete transcript discovery/snapshot composition behind an injected sync transcript runtime contract, with `src/platform/transcripts/runtime.ts` wired by the CLI sync edge.
5757
- Moved sync's internal-Almanac-session lookup behind a jobs-service provider-session helper, so sync no longer reads job record storage shape directly.
5858
- Split sync workflow helpers into owned files for input parsing, transcript candidate shaping, summary projection, and Absorb context rendering.
59+
- Split sync sweep helpers into owned files for candidate eligibility, internal-session detection, and Absorb enqueue/ledger transitions.
5960
- Moved lifecycle operation construction and Absorb input/source handling into `src/services/lifecycle/` and removed the old top-level `src/operations/` and `src/absorb/` source buckets.
6061
- Normalized lifecycle operation failures into lifecycle-owned result contracts before command rendering sees them.
6162
- Moved init prompt context construction out of the operation command adapter and into lifecycle workflows, so command code only shapes flags into service requests and renders service results.
@@ -108,7 +109,7 @@ This is no longer a small cleanup branch. It is a real ownership rewrite.
108109

109110
## Latest Checkpoint
110111

111-
The latest slice split the sync workflow helper logic into owned files so `src/services/sync/sync.ts` reads as orchestration instead of a mixed helper bucket.
112+
The latest slice split sync sweep helper logic into owned files so `src/services/sync/sweep.ts` reads as candidate orchestration instead of a mixed eligibility/session/enqueue bucket.
112113

113114
Verification passed:
114115

@@ -117,8 +118,8 @@ Verification passed:
117118
- `npx vitest run test/architecture-boundaries.test.ts test/sync.test.ts`
118119
- `npm test`
119120
- `npm run build`
120-
- `node dist/launcher.js sync status --json --quiet 1m`
121121
- `node dist/launcher.js sync status --help`
122+
- `node dist/launcher.js sync status --from codex --quiet 999999999s --json`
122123
- `node dist/launcher.js search transcript --limit 1`
123124

124125
## Immediate Next Work

docs/intentional-architecture-rewrite-2026-06-27/worklog.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1630,3 +1630,13 @@ Two-hundred-thirty-second production slice:
16301630
- Kept config `sync_since` lookup and lifecycle Absorb handoff in `sync.ts` because those are the workflow decisions the top-level sync verb coordinates.
16311631
- Reduced `src/services/sync/sync.ts` from a large mixed file to a small orchestrator over named sync helpers.
16321632
- Strengthened architecture-boundary tests so sync parsing, summary projection, repo candidate shaping, and Absorb context rendering stay in their owned files.
1633+
1634+
Two-hundred-thirty-third production slice:
1635+
1636+
- Split `src/services/sync/sweep.ts` so it stays focused on per-candidate sweep orchestration instead of owning every helper decision inline.
1637+
- Added `src/services/sync/candidate-eligibility.ts` for activation and quiet-window eligibility checks.
1638+
- Added `src/services/sync/internal-sessions.ts` for the internal-Almanac-session skip check over job provider session ids.
1639+
- Added `src/services/sync/absorb-enqueue.ts` for ready-cursor-to-Absorb-job handoff, sync cursor context text, and the resulting ledger transition.
1640+
- Kept ledger loading/reconciliation, repo sync locking, transcript snapshot reads, and summary accounting visible in `sweep.ts` because those are the sweep workflow steps.
1641+
- Moved cursor context text out of `sweep-results.ts`, leaving that file as summary projection only.
1642+
- Strengthened architecture-boundary tests so `sweep.ts` does not regain provider-session lookup or cursor context text.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type {
2+
TranscriptCandidate,
3+
TranscriptSnapshot,
4+
} from "../../shared/transcripts.js";
5+
import type { LedgerEntry } from "./ledger.js";
6+
import {
7+
failedLedgerEntry,
8+
pendingLedgerEntry,
9+
type SyncCursorDecision,
10+
} from "./transcript-cursor.js";
11+
12+
export interface StartSyncAbsorbArgs {
13+
candidate: TranscriptCandidate;
14+
contextNote: string;
15+
}
16+
17+
export type StartSyncAbsorbResult =
18+
| { ok: true; jobId: string }
19+
| { ok: false; error: string };
20+
21+
export type StartSyncAbsorbFn = (
22+
args: StartSyncAbsorbArgs,
23+
) => Promise<StartSyncAbsorbResult>;
24+
25+
export async function enqueueSyncAbsorb(args: {
26+
candidate: TranscriptCandidate;
27+
entry: LedgerEntry;
28+
decision: Extract<SyncCursorDecision, { kind: "ready" }>;
29+
snapshot: TranscriptSnapshot;
30+
now: Date;
31+
startAbsorb: StartSyncAbsorbFn;
32+
}): Promise<
33+
| { ok: true; jobId: string; entry: LedgerEntry }
34+
| { ok: false; reason: string; entry: LedgerEntry }
35+
> {
36+
const result = await args.startAbsorb({
37+
candidate: args.candidate,
38+
contextNote: syncCursorContext({
39+
candidate: args.candidate,
40+
fromLine: args.decision.fromLine,
41+
lastAbsorbedLine: args.entry.lastAbsorbedLine,
42+
lastAbsorbedSize: args.entry.lastAbsorbedSize,
43+
}),
44+
});
45+
if (!result.ok) {
46+
return {
47+
ok: false,
48+
reason: "absorb-start-failed",
49+
entry: failedLedgerEntry(args.entry, result.error),
50+
};
51+
}
52+
return {
53+
ok: true,
54+
jobId: result.jobId,
55+
entry: pendingLedgerEntry({
56+
entry: args.entry,
57+
snapshot: args.snapshot,
58+
jobId: result.jobId,
59+
now: args.now,
60+
}),
61+
};
62+
}
63+
64+
function syncCursorContext(args: {
65+
candidate: TranscriptCandidate;
66+
fromLine: number;
67+
lastAbsorbedLine: number;
68+
lastAbsorbedSize: number;
69+
}): string {
70+
return [
71+
"Scheduled sync cursor:",
72+
`- App: ${args.candidate.app}`,
73+
`- Session id: ${args.candidate.sessionId}`,
74+
`- Transcript: ${args.candidate.transcriptPath}`,
75+
`- Previously absorbed through line: ${args.lastAbsorbedLine}`,
76+
`- Previously absorbed through byte: ${args.lastAbsorbedSize}`,
77+
`- Focus on line ${args.fromLine} onward.`,
78+
"- You may inspect earlier lines only for context.",
79+
"- Do not re-document decisions already absorbed unless newer lines amend, " +
80+
"invalidate, or add important nuance to them.",
81+
].join("\n");
82+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { TranscriptCandidate } from "../../shared/transcripts.js";
2+
import { syncSkippedSummary, type SyncSkipped } from "./sweep-results.js";
3+
4+
export function syncCandidateEligibility(
5+
candidate: TranscriptCandidate,
6+
args: {
7+
syncSince: Date | null;
8+
quietMs: number;
9+
now: Date;
10+
},
11+
): SyncSkipped | null {
12+
if (args.syncSince !== null && candidate.mtimeMs < args.syncSince.getTime()) {
13+
return syncSkippedSummary(candidate, "before-automation-activation");
14+
}
15+
16+
const quietForMs = args.now.getTime() - candidate.mtimeMs;
17+
if (quietForMs < args.quietMs) {
18+
return syncSkippedSummary(candidate, "quiet-window");
19+
}
20+
return null;
21+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { TranscriptCandidate } from "../../shared/transcripts.js";
2+
import { listJobProviderSessionIds } from "../jobs/index.js";
3+
4+
export async function isInternalAlmanacSession(
5+
candidate: TranscriptCandidate,
6+
cache: Map<string, Set<string>>,
7+
): Promise<boolean> {
8+
let ids = cache.get(candidate.repoRoot);
9+
if (ids === undefined) {
10+
ids = await listJobProviderSessionIds(candidate.repoRoot);
11+
cache.set(candidate.repoRoot, ids);
12+
}
13+
return ids.has(candidate.sessionId);
14+
}

src/services/sync/sweep-results.ts

Lines changed: 4 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import type { TranscriptCandidate, TranscriptSourceApp } from "../../shared/transcripts.js";
1+
import type {
2+
TranscriptCandidate,
3+
TranscriptSourceApp,
4+
} from "../../shared/transcripts.js";
25
import type { SyncCursorDecision } from "./transcript-cursor.js";
36

47
export interface SyncStarted {
@@ -98,23 +101,3 @@ export function syncSkippedSummary(
98101
reason,
99102
};
100103
}
101-
102-
export function syncCursorContext(args: {
103-
candidate: TranscriptCandidate;
104-
fromLine: number;
105-
lastAbsorbedLine: number;
106-
lastAbsorbedSize: number;
107-
}): string {
108-
return [
109-
"Scheduled sync cursor:",
110-
`- App: ${args.candidate.app}`,
111-
`- Session id: ${args.candidate.sessionId}`,
112-
`- Transcript: ${args.candidate.transcriptPath}`,
113-
`- Previously absorbed through line: ${args.lastAbsorbedLine}`,
114-
`- Previously absorbed through byte: ${args.lastAbsorbedSize}`,
115-
`- Focus on line ${args.fromLine} onward.`,
116-
"- You may inspect earlier lines only for context.",
117-
"- Do not re-document decisions already absorbed unless newer lines amend, " +
118-
"invalidate, or add important nuance to them.",
119-
].join("\n");
120-
}

src/services/sync/sweep.ts

Lines changed: 18 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,34 @@
11
import {
22
type TranscriptCandidate,
33
type TranscriptReadResult,
4-
type TranscriptSnapshot,
54
} from "../../shared/transcripts.js";
65
import {
7-
type LedgerEntry,
86
type SyncLedger,
97
freshLedgerEntry,
108
ledgerKey,
119
reconcileLedger,
1210
} from "./ledger.js";
11+
import { evaluateSyncCursor } from "./transcript-cursor.js";
1312
import {
14-
type SyncCursorDecision,
15-
evaluateSyncCursor,
16-
failedLedgerEntry,
17-
pendingLedgerEntry,
18-
} from "./transcript-cursor.js";
19-
import {
20-
type SyncSkipped,
2113
type SyncSummary,
2214
emptySyncSummary,
23-
syncCursorContext,
2415
syncReadySummary,
2516
syncSkippedSummary,
2617
syncStartedSummary,
2718
} from "./sweep-results.js";
2819
import type { IsPidAlive } from "../../shared/pid-liveness.js";
20+
import {
21+
enqueueSyncAbsorb,
22+
type StartSyncAbsorbFn,
23+
} from "./absorb-enqueue.js";
24+
import { syncCandidateEligibility } from "./candidate-eligibility.js";
25+
import { isInternalAlmanacSession } from "./internal-sessions.js";
2926
import {
3027
loadLedgerForRepo,
3128
writeLedger,
3229
} from "../../stores/sync/ledger.js";
3330
import { acquireRepoSyncLock, releaseRepoSyncLock } from "../../stores/sync/lock.js";
34-
import { listJobProviderSessionIds } from "../jobs/index.js";
35-
36-
export interface StartSyncAbsorbArgs {
37-
candidate: TranscriptCandidate;
38-
contextNote: string;
39-
}
40-
41-
export type StartSyncAbsorbResult =
42-
| { ok: true; jobId: string }
43-
| { ok: false; error: string };
4431

45-
export type StartSyncAbsorbFn = (
46-
args: StartSyncAbsorbArgs,
47-
) => Promise<StartSyncAbsorbResult>;
4832
export type ReadSyncTranscriptSnapshotFn = (
4933
transcriptPath: string,
5034
) => Promise<TranscriptReadResult>;
@@ -71,7 +55,7 @@ export async function executeSyncSweep(args: {
7155
const heldLocks = new Set<string>();
7256
try {
7357
for (const candidate of args.candidates) {
74-
const eligibilitySkip = candidateEligibility(candidate, args);
58+
const eligibilitySkip = syncCandidateEligibility(candidate, args);
7559
if (eligibilitySkip !== null) {
7660
summary.skipped.push(eligibilitySkip);
7761
continue;
@@ -102,9 +86,13 @@ export async function executeSyncSweep(args: {
10286
await reconcileLedger(candidate.repoRoot, ledger, args.now);
10387
const key = ledgerKey(candidate);
10488

105-
const transcript = await args.readTranscriptSnapshot(candidate.transcriptPath);
89+
const transcript = await args.readTranscriptSnapshot(
90+
candidate.transcriptPath,
91+
);
10692
if (!transcript.ok) {
107-
summary.needsAttention.push(syncSkippedSummary(candidate, transcript.reason));
93+
summary.needsAttention.push(
94+
syncSkippedSummary(candidate, transcript.reason),
95+
);
10896
continue;
10997
}
11098
const entry = ledger.sessions[key] ??
@@ -130,7 +118,7 @@ export async function executeSyncSweep(args: {
130118
continue;
131119
}
132120

133-
const enqueue = await enqueueAbsorb({
121+
const enqueue = await enqueueSyncAbsorb({
134122
candidate,
135123
entry,
136124
decision,
@@ -140,7 +128,9 @@ export async function executeSyncSweep(args: {
140128
});
141129
if (!enqueue.ok) {
142130
ledger.sessions[key] = enqueue.entry;
143-
summary.needsAttention.push(syncSkippedSummary(candidate, enqueue.reason));
131+
summary.needsAttention.push(
132+
syncSkippedSummary(candidate, enqueue.reason),
133+
);
144134
await writeLedger(candidate.repoRoot, ledger, args.now);
145135
continue;
146136
}
@@ -162,73 +152,3 @@ export async function executeSyncSweep(args: {
162152

163153
return summary;
164154
}
165-
166-
async function isInternalAlmanacSession(
167-
candidate: TranscriptCandidate,
168-
cache: Map<string, Set<string>>,
169-
): Promise<boolean> {
170-
let ids = cache.get(candidate.repoRoot);
171-
if (ids === undefined) {
172-
ids = await listJobProviderSessionIds(candidate.repoRoot);
173-
cache.set(candidate.repoRoot, ids);
174-
}
175-
return ids.has(candidate.sessionId);
176-
}
177-
178-
function candidateEligibility(
179-
candidate: TranscriptCandidate,
180-
args: {
181-
syncSince: Date | null;
182-
quietMs: number;
183-
now: Date;
184-
},
185-
): SyncSkipped | null {
186-
if (args.syncSince !== null && candidate.mtimeMs < args.syncSince.getTime()) {
187-
return syncSkippedSummary(candidate, "before-automation-activation");
188-
}
189-
190-
const quietForMs = args.now.getTime() - candidate.mtimeMs;
191-
if (quietForMs < args.quietMs) {
192-
return syncSkippedSummary(candidate, "quiet-window");
193-
}
194-
return null;
195-
}
196-
197-
async function enqueueAbsorb(args: {
198-
candidate: TranscriptCandidate;
199-
entry: LedgerEntry;
200-
decision: Extract<SyncCursorDecision, { kind: "ready" }>;
201-
snapshot: TranscriptSnapshot;
202-
now: Date;
203-
startAbsorb: StartSyncAbsorbFn;
204-
}): Promise<
205-
| { ok: true; jobId: string; entry: LedgerEntry }
206-
| { ok: false; reason: string; entry: LedgerEntry }
207-
> {
208-
const result = await args.startAbsorb({
209-
candidate: args.candidate,
210-
contextNote: syncCursorContext({
211-
candidate: args.candidate,
212-
fromLine: args.decision.fromLine,
213-
lastAbsorbedLine: args.entry.lastAbsorbedLine,
214-
lastAbsorbedSize: args.entry.lastAbsorbedSize,
215-
}),
216-
});
217-
if (!result.ok) {
218-
return {
219-
ok: false,
220-
reason: "absorb-start-failed",
221-
entry: failedLedgerEntry(args.entry, result.error),
222-
};
223-
}
224-
return {
225-
ok: true,
226-
jobId: result.jobId,
227-
entry: pendingLedgerEntry({
228-
entry: args.entry,
229-
snapshot: args.snapshot,
230-
jobId: result.jobId,
231-
now: args.now,
232-
}),
233-
};
234-
}

0 commit comments

Comments
 (0)