kankaku 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -60,7 +60,11 @@ function isThrottled(state, trigger, now, minIntervalMs) {
60
60
  export async function runSync(deps, options = {}) {
61
61
  const startedAt = deps.clock.now();
62
62
  if (options.trigger !== undefined) {
63
- const peek = deps.stateStore.read();
63
+ // Gated like every other read below: a state written for another hub
64
+ // says nothing about THIS hub, so neither its `logVersion` (which
65
+ // would otherwise skip the new hub's very first run until the next
66
+ // record is appended) nor its `lastRunAt` throttle may apply.
67
+ const peek = stateForTarget(deps, deps.stateStore.read());
64
68
  const currentVersion = deps.log.version?.();
65
69
  const versionUnchanged = currentVersion !== undefined && peek?.logVersion === currentVersion;
66
70
  if (versionUnchanged && peek?.lastError === undefined) {
@@ -75,10 +79,17 @@ export async function runSync(deps, options = {}) {
75
79
  try {
76
80
  lockAcquired = deps.stateStore.tryLock();
77
81
  if (!lockAcquired) {
78
- const state = deps.stateStore.read();
82
+ const state = stateForTarget(deps, deps.stateStore.read());
79
83
  return { ...emptySummary(deps.clock.now() - startedAt, state?.syncedThrough), locked: true };
80
84
  }
81
85
  const state = deps.stateStore.read();
86
+ // A state from another hub is ignored wholesale, on EVERY path below
87
+ // (the summary, the error path, and the successful merge): its
88
+ // watermark and its hashes describe rows the configured hub does not
89
+ // have (mirrors `domain/sync-plan.ts#planSync`'s gate). `planSync`
90
+ // still receives the raw `state`, since it applies that same gate
91
+ // itself and its `target` comparison is what makes the run a full one.
92
+ const priorState = stateForTarget(deps, state);
82
93
  // Captured once, here, and persisted as-is below: this is the version
83
94
  // the tasks below were actually built from, not whatever the log might
84
95
  // become by the time an awaited push finishes.
@@ -93,10 +104,15 @@ export async function runSync(deps, options = {}) {
93
104
  // WorkSink implementations are expected never to throw, but this
94
105
  // runner must hold that guarantee even if one does.
95
106
  const message = error instanceof Error ? error.message : String(error);
96
- const summary = emptySummary(deps.clock.now() - startedAt, state?.syncedThrough);
107
+ const summary = emptySummary(deps.clock.now() - startedAt, priorState?.syncedThrough);
97
108
  summary.skipped = plan.unchangedCount;
98
109
  summary.error = message;
99
- persistError(deps, state, message, logVersionAtRead);
110
+ // `priorState`, never `state`: a throwing sink on the first run
111
+ // against a NEW hub must not carry the old hub's hashes and
112
+ // watermark over under the new target, or the next run would
113
+ // skip every task as "unchanged" (the exact bug 0.8.1 fixed on the
114
+ // successful path).
115
+ persistError(deps, priorState, message, logVersionAtRead);
100
116
  return summary;
101
117
  }
102
118
  const byId = new Map(plan.toSync.map((task) => [task.id, task]));
@@ -111,7 +127,7 @@ export async function runSync(deps, options = {}) {
111
127
  const unassigned = new Map();
112
128
  let uploaded = 0;
113
129
  let updated = 0;
114
- let syncedThrough = state?.syncedThrough;
130
+ let syncedThrough = priorState?.syncedThrough;
115
131
  let stopError;
116
132
  // Whether at least one task was actually resolved (pushed or recorded
117
133
  // as failed) this run — as opposed to the run stopping on its very
@@ -147,13 +163,16 @@ export async function runSync(deps, options = {}) {
147
163
  }
148
164
  else {
149
165
  // "error": a network/timeout/5xx/auth failure. Stop here — nothing
150
- // after this point in the (chronologically sorted) results is
151
- // considered resolved, so syncedThrough does not advance past it.
166
+ // after this point in the results is considered resolved. The
167
+ // results follow `plan.toSync`'s order (new work oldest-first,
168
+ // then corrections newest-first), and `syncedThrough` is a max
169
+ // over what WAS resolved, so stopping early can only leave it
170
+ // lower, never advance it past an unresolved task.
152
171
  stopError = result.outcome.reason;
153
172
  break;
154
173
  }
155
174
  }
156
- const mergedHashes = { ...(state?.hashes ?? {}), ...Object.fromEntries(newHashes) };
175
+ const mergedHashes = { ...(priorState?.hashes ?? {}), ...Object.fromEntries(newHashes) };
157
176
  // G2: no longer window-bound — see `domain/sync-plan.ts#pruneHashes`'s
158
177
  // doc comment. `tasks` here is every task `buildTasks` currently knows
159
178
  // about (the full `readAll()`, not just this run's eligible/window
@@ -188,6 +207,11 @@ export async function runSync(deps, options = {}) {
188
207
  deps.stateStore.unlock();
189
208
  }
190
209
  }
210
+ /** `state` when it was written against `deps.target`; `undefined` (as if there were no state at all) when it belongs to another hub. */
211
+ function stateForTarget(deps, state) {
212
+ return state !== undefined && state.target === deps.target ? state : undefined;
213
+ }
214
+ /** Persist a failed run. `state` must already be gated by {@link stateForTarget}: what it carries is re-written under `deps.target`. */
191
215
  function persistError(deps, state, message, logVersionAtRead) {
192
216
  deps.stateStore.write({
193
217
  target: deps.target,
@@ -14,7 +14,7 @@ import type { TaskView } from "./task-view.ts";
14
14
  export interface SyncState {
15
15
  /** High-watermark ISO timestamp: everything with `endedAt` at or before `syncedThrough - window` is considered done. `undefined` before the first successful sync. */
16
16
  syncedThrough?: string;
17
- /** Content hash per task id (see {@link computeTaskContentHash}), pruned to the revisit window so the file stays small. */
17
+ /** Content hash per task id (see {@link computeTaskContentHash}), kept for as long as the task exists — never pruned by the revisit window (see {@link pruneHashes}). */
18
18
  hashes: Record<string, string>;
19
19
  /** The hub URL this state was synced against; a state written for a different URL is treated as absent (full sync). */
20
20
  target: string;
@@ -36,7 +36,8 @@ export interface SyncState {
36
36
  * `Clock`-based timestamp (ms) of the last real (non-short-circuited)
37
37
  * automatic sync attempt, persisted so the automatic path's throttle
38
38
  * (`KANKAKU_SYNC_MIN_INTERVAL_MINUTES`) holds across processes, not just
39
- * within one. Never touched by a manual sync.
39
+ * within one. Written by every run that reaches the sink, manual or
40
+ * automatic; only the automatic path READS it to throttle itself.
40
41
  */
41
42
  lastRunAt?: number;
42
43
  }
@@ -49,7 +50,7 @@ export interface SyncPlanOptions {
49
50
  full?: boolean;
50
51
  }
51
52
  export interface SyncPlan {
52
- /** Tasks whose content changed (or were never synced) and therefore need a request, in chronological (`endedAt`) order. */
53
+ /** Tasks whose content changed (or were never synced) and therefore need a request: new work in the window oldest-first, then corrections to rows the hub already holds, newest-first (see {@link planSync}). */
53
54
  toSync: TaskView[];
54
55
  /** How many eligible tasks were skipped because their stored hash already matched — no request needed for them. */
55
56
  unchangedCount: number;
@@ -115,8 +116,8 @@ export declare function planSync(tasks: TaskView[], state: SyncState | undefined
115
116
  * for "unchanged but I forgot"), so that window-based pruning made every
116
117
  * task older than the window look permanently changed, forever, the moment
117
118
  * its hash was first pruned: `/kankaku sync status` would report a
118
- * never-shrinking "changed outside the window" count that training taught
119
- * users to ignore (the bug this rewrite fixes).
119
+ * never-shrinking "changed outside the window" count that trained users
120
+ * to ignore it (the bug this rewrite fixes).
120
121
  *
121
122
  * Never pruning by window instead means `hashes` grows with the total
122
123
  * number of distinct tasks a directory has ever synced, not with time — an
@@ -110,7 +110,11 @@ export function planSync(tasks, state, options) {
110
110
  eligible = sorted.filter((task) => Date.parse(task.endedAt) > cutoff);
111
111
  outsideWindow = sorted.filter((task) => Date.parse(task.endedAt) <= cutoff);
112
112
  }
113
- const hashes = state?.hashes ?? {};
113
+ // A state written for another hub contributes nothing, not even its
114
+ // hashes: the new hub has none of these rows, so a task that matched
115
+ // the OLD hub's hash must still be pushed. `isFullSync` alone only
116
+ // widened the window; without this gate every task looked "unchanged".
117
+ const hashes = state !== undefined && state.target === options.target ? state.hashes : {};
114
118
  const changed = (task) => hashes[task.id] !== computeTaskContentHash(task);
115
119
  // A row the hub ALREADY holds is corrected wherever it sits: the window
116
120
  // bounds how far back NEW work is looked for, never whether a known row
@@ -128,10 +132,10 @@ export function planSync(tasks, state, options) {
128
132
  const knownAndChanged = isFullSync ? allCorrections : allCorrections.slice(0, MAX_CORRECTIONS_PER_RUN);
129
133
  const correctionsDeferred = allCorrections.length - knownAndChanged.length;
130
134
  const toSync = [...eligible.filter(changed), ...knownAndChanged];
131
- // R3: cheap, pure visibility into a task that changed but that this
132
- // incremental run's window will not re-evaluate — see SyncPlan's doc
133
- // comment. No extra work: `outsideWindow` is already computed above,
134
- // this just re-applies the same hash-mismatch check to it.
135
+ // R3: cheap, pure visibility into a task this incremental run's window
136
+ // will not look at and that this hub has NEVER received (no stored hash)
137
+ // — see SyncPlan's doc comment. A known row that changed is a correction
138
+ // and is handled above, not reported here.
135
139
  const staleOutsideWindow = outsideWindow.filter((task) => hashes[task.id] === undefined);
136
140
  return { toSync, unchangedCount: eligible.length - eligible.filter(changed).length, isFullSync, staleOutsideWindow, correctionsDeferred };
137
141
  }
@@ -149,8 +153,8 @@ export function planSync(tasks, state, options) {
149
153
  * for "unchanged but I forgot"), so that window-based pruning made every
150
154
  * task older than the window look permanently changed, forever, the moment
151
155
  * its hash was first pruned: `/kankaku sync status` would report a
152
- * never-shrinking "changed outside the window" count that training taught
153
- * users to ignore (the bug this rewrite fixes).
156
+ * never-shrinking "changed outside the window" count that trained users
157
+ * to ignore it (the bug this rewrite fixes).
154
158
  *
155
159
  * Never pruning by window instead means `hashes` grows with the total
156
160
  * number of distinct tasks a directory has ever synced, not with time — an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kankaku",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "pi extension that records agent work time per prompt, excluding waits for the user, with subagent linkage and task/session views",
5
5
  "license": "MIT",
6
6
  "author": "soyunninja",
@@ -108,7 +108,11 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
108
108
  const startedAt = deps.clock.now();
109
109
 
110
110
  if (options.trigger !== undefined) {
111
- const peek = deps.stateStore.read();
111
+ // Gated like every other read below: a state written for another hub
112
+ // says nothing about THIS hub, so neither its `logVersion` (which
113
+ // would otherwise skip the new hub's very first run until the next
114
+ // record is appended) nor its `lastRunAt` throttle may apply.
115
+ const peek = stateForTarget(deps, deps.stateStore.read());
112
116
  const currentVersion = deps.log.version?.();
113
117
  const versionUnchanged = currentVersion !== undefined && peek?.logVersion === currentVersion;
114
118
  if (versionUnchanged && peek?.lastError === undefined) {
@@ -125,11 +129,18 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
125
129
  try {
126
130
  lockAcquired = deps.stateStore.tryLock();
127
131
  if (!lockAcquired) {
128
- const state = deps.stateStore.read();
132
+ const state = stateForTarget(deps, deps.stateStore.read());
129
133
  return { ...emptySummary(deps.clock.now() - startedAt, state?.syncedThrough), locked: true };
130
134
  }
131
135
 
132
136
  const state = deps.stateStore.read();
137
+ // A state from another hub is ignored wholesale, on EVERY path below
138
+ // (the summary, the error path, and the successful merge): its
139
+ // watermark and its hashes describe rows the configured hub does not
140
+ // have (mirrors `domain/sync-plan.ts#planSync`'s gate). `planSync`
141
+ // still receives the raw `state`, since it applies that same gate
142
+ // itself and its `target` comparison is what makes the run a full one.
143
+ const priorState = stateForTarget(deps, state);
133
144
  // Captured once, here, and persisted as-is below: this is the version
134
145
  // the tasks below were actually built from, not whatever the log might
135
146
  // become by the time an awaited push finishes.
@@ -144,10 +155,15 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
144
155
  // WorkSink implementations are expected never to throw, but this
145
156
  // runner must hold that guarantee even if one does.
146
157
  const message = error instanceof Error ? error.message : String(error);
147
- const summary = emptySummary(deps.clock.now() - startedAt, state?.syncedThrough);
158
+ const summary = emptySummary(deps.clock.now() - startedAt, priorState?.syncedThrough);
148
159
  summary.skipped = plan.unchangedCount;
149
160
  summary.error = message;
150
- persistError(deps, state, message, logVersionAtRead);
161
+ // `priorState`, never `state`: a throwing sink on the first run
162
+ // against a NEW hub must not carry the old hub's hashes and
163
+ // watermark over under the new target, or the next run would
164
+ // skip every task as "unchanged" (the exact bug 0.8.1 fixed on the
165
+ // successful path).
166
+ persistError(deps, priorState, message, logVersionAtRead);
151
167
  return summary;
152
168
  }
153
169
 
@@ -163,7 +179,7 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
163
179
  const unassigned = new Map<string, number>();
164
180
  let uploaded = 0;
165
181
  let updated = 0;
166
- let syncedThrough = state?.syncedThrough;
182
+ let syncedThrough = priorState?.syncedThrough;
167
183
  let stopError: string | undefined;
168
184
  // Whether at least one task was actually resolved (pushed or recorded
169
185
  // as failed) this run — as opposed to the run stopping on its very
@@ -196,14 +212,17 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
196
212
  progressed = true;
197
213
  } else {
198
214
  // "error": a network/timeout/5xx/auth failure. Stop here — nothing
199
- // after this point in the (chronologically sorted) results is
200
- // considered resolved, so syncedThrough does not advance past it.
215
+ // after this point in the results is considered resolved. The
216
+ // results follow `plan.toSync`'s order (new work oldest-first,
217
+ // then corrections newest-first), and `syncedThrough` is a max
218
+ // over what WAS resolved, so stopping early can only leave it
219
+ // lower, never advance it past an unresolved task.
201
220
  stopError = result.outcome.reason;
202
221
  break;
203
222
  }
204
223
  }
205
224
 
206
- const mergedHashes = { ...(state?.hashes ?? {}), ...Object.fromEntries(newHashes) };
225
+ const mergedHashes = { ...(priorState?.hashes ?? {}), ...Object.fromEntries(newHashes) };
207
226
  // G2: no longer window-bound — see `domain/sync-plan.ts#pruneHashes`'s
208
227
  // doc comment. `tasks` here is every task `buildTasks` currently knows
209
228
  // about (the full `readAll()`, not just this run's eligible/window
@@ -239,6 +258,12 @@ export async function runSync(deps: SyncRunnerDeps, options: { full?: boolean; t
239
258
  }
240
259
  }
241
260
 
261
+ /** `state` when it was written against `deps.target`; `undefined` (as if there were no state at all) when it belongs to another hub. */
262
+ function stateForTarget(deps: SyncRunnerDeps, state: ReturnType<SyncStateStore["read"]>): ReturnType<SyncStateStore["read"]> {
263
+ return state !== undefined && state.target === deps.target ? state : undefined;
264
+ }
265
+
266
+ /** Persist a failed run. `state` must already be gated by {@link stateForTarget}: what it carries is re-written under `deps.target`. */
242
267
  function persistError(deps: SyncRunnerDeps, state: ReturnType<SyncStateStore["read"]>, message: string, logVersionAtRead: string | number | undefined): void {
243
268
  deps.stateStore.write({
244
269
  target: deps.target,
@@ -17,7 +17,7 @@ import type { TaskView } from "./task-view.ts";
17
17
  export interface SyncState {
18
18
  /** High-watermark ISO timestamp: everything with `endedAt` at or before `syncedThrough - window` is considered done. `undefined` before the first successful sync. */
19
19
  syncedThrough?: string;
20
- /** Content hash per task id (see {@link computeTaskContentHash}), pruned to the revisit window so the file stays small. */
20
+ /** Content hash per task id (see {@link computeTaskContentHash}), kept for as long as the task exists — never pruned by the revisit window (see {@link pruneHashes}). */
21
21
  hashes: Record<string, string>;
22
22
  /** The hub URL this state was synced against; a state written for a different URL is treated as absent (full sync). */
23
23
  target: string;
@@ -36,7 +36,8 @@ export interface SyncState {
36
36
  * `Clock`-based timestamp (ms) of the last real (non-short-circuited)
37
37
  * automatic sync attempt, persisted so the automatic path's throttle
38
38
  * (`KANKAKU_SYNC_MIN_INTERVAL_MINUTES`) holds across processes, not just
39
- * within one. Never touched by a manual sync.
39
+ * within one. Written by every run that reaches the sink, manual or
40
+ * automatic; only the automatic path READS it to throttle itself.
40
41
  */
41
42
  lastRunAt?: number;
42
43
  }
@@ -51,7 +52,7 @@ export interface SyncPlanOptions {
51
52
  }
52
53
 
53
54
  export interface SyncPlan {
54
- /** Tasks whose content changed (or were never synced) and therefore need a request, in chronological (`endedAt`) order. */
55
+ /** Tasks whose content changed (or were never synced) and therefore need a request: new work in the window oldest-first, then corrections to rows the hub already holds, newest-first (see {@link planSync}). */
55
56
  toSync: TaskView[];
56
57
  /** How many eligible tasks were skipped because their stored hash already matched — no request needed for them. */
57
58
  unchangedCount: number;
@@ -177,7 +178,11 @@ export function planSync(tasks: TaskView[], state: SyncState | undefined, option
177
178
  outsideWindow = sorted.filter((task) => Date.parse(task.endedAt) <= cutoff);
178
179
  }
179
180
 
180
- const hashes = state?.hashes ?? {};
181
+ // A state written for another hub contributes nothing, not even its
182
+ // hashes: the new hub has none of these rows, so a task that matched
183
+ // the OLD hub's hash must still be pushed. `isFullSync` alone only
184
+ // widened the window; without this gate every task looked "unchanged".
185
+ const hashes = state !== undefined && state.target === options.target ? state.hashes : {};
181
186
  const changed = (task: TaskView): boolean => hashes[task.id] !== computeTaskContentHash(task);
182
187
  // A row the hub ALREADY holds is corrected wherever it sits: the window
183
188
  // bounds how far back NEW work is looked for, never whether a known row
@@ -195,10 +200,10 @@ export function planSync(tasks: TaskView[], state: SyncState | undefined, option
195
200
  const knownAndChanged = isFullSync ? allCorrections : allCorrections.slice(0, MAX_CORRECTIONS_PER_RUN);
196
201
  const correctionsDeferred = allCorrections.length - knownAndChanged.length;
197
202
  const toSync = [...eligible.filter(changed), ...knownAndChanged];
198
- // R3: cheap, pure visibility into a task that changed but that this
199
- // incremental run's window will not re-evaluate — see SyncPlan's doc
200
- // comment. No extra work: `outsideWindow` is already computed above,
201
- // this just re-applies the same hash-mismatch check to it.
203
+ // R3: cheap, pure visibility into a task this incremental run's window
204
+ // will not look at and that this hub has NEVER received (no stored hash)
205
+ // — see SyncPlan's doc comment. A known row that changed is a correction
206
+ // and is handled above, not reported here.
202
207
  const staleOutsideWindow = outsideWindow.filter((task) => hashes[task.id] === undefined);
203
208
 
204
209
  return { toSync, unchangedCount: eligible.length - eligible.filter(changed).length, isFullSync, staleOutsideWindow, correctionsDeferred };
@@ -218,8 +223,8 @@ export function planSync(tasks: TaskView[], state: SyncState | undefined, option
218
223
  * for "unchanged but I forgot"), so that window-based pruning made every
219
224
  * task older than the window look permanently changed, forever, the moment
220
225
  * its hash was first pruned: `/kankaku sync status` would report a
221
- * never-shrinking "changed outside the window" count that training taught
222
- * users to ignore (the bug this rewrite fixes).
226
+ * never-shrinking "changed outside the window" count that trained users
227
+ * to ignore it (the bug this rewrite fixes).
223
228
  *
224
229
  * Never pruning by window instead means `hashes` grows with the total
225
230
  * number of distinct tasks a directory has ever synced, not with time — an