pi-cc-loop 0.7.6 → 0.7.7

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.
package/README.md CHANGED
@@ -60,6 +60,15 @@ ln -sfn "$PWD" ~/.pi/agent/extensions/pi-cc-loop
60
60
 
61
61
  可以在请求里加 `--durable`(写入 `.pi/scheduled_tasks.json`,重启后继续)和 `--max N`(触发 N 次后自动删除)。
62
62
 
63
+ ### 持久任务与子代理
64
+
65
+ 同一项目的持久任务只由一个调度器实例执行。锁用 PID、主机名和每个实例独有的令牌识别归属,
66
+ 所以主会话与 subagent 即使在同一个 Pi 进程里,也不会同时接管同一批任务;其他会话仍能添加、查看或取消任务。
67
+ 派发持久任务前会重新核验并续期锁,失去归属的实例停止执行已载入的持久任务,不影响它的会话级任务。
68
+
69
+ `/reload` 先由旧实例释放锁,新实例再获取;持锁进程退出或心跳超过 60 秒未更新时,其他实例可以接管。
70
+ 旧实例不会续写或删除新实例的锁。旧版没有实例令牌的锁仍被尊重,释放或过期后才换成新版格式;任务文件无需迁移。
71
+
63
72
  ## 工具
64
73
 
65
74
  | 工具 | 界面显示 | 说明 |
package/durable.ts CHANGED
@@ -16,7 +16,8 @@
16
16
  * - The session holding the lock runs the durable jobs and picks up jobs
17
17
  * other sessions add or remove. The lock carries a heartbeat; a lock whose
18
18
  * heartbeat is older than a minute (or whose process is gone) is stale and
19
- * can be taken over.
19
+ * can be taken over. A PID identifies a process, not a session: main and
20
+ * subagent sessions in that process each get their own ownership token.
20
21
  * - The runtime files are added to `.git/info/exclude`.
21
22
  */
22
23
 
@@ -132,6 +133,8 @@ export class DurableStore {
132
133
  interface LockInfo {
133
134
  pid: number;
134
135
  hostname: string;
136
+ /** Absent on legacy locks, which remain valid until released or stale. */
137
+ ownerToken?: string;
135
138
  acquiredAt: number;
136
139
  heartbeatAt: number;
137
140
  }
@@ -159,6 +162,7 @@ export class SchedulerLock {
159
162
  owned = false;
160
163
  private readonly pid: number;
161
164
  private readonly hostname: string;
165
+ private readonly ownerToken = randomUUID();
162
166
  private readonly isAlive: (pid: number) => boolean;
163
167
  private readonly staleMs: number;
164
168
 
@@ -184,57 +188,76 @@ export class SchedulerLock {
184
188
  return info.hostname === this.hostname && !this.isAlive(info.pid);
185
189
  }
186
190
 
191
+ private matches(info: LockInfo | null): boolean {
192
+ return info?.pid === this.pid && info.hostname === this.hostname && info.ownerToken === this.ownerToken;
193
+ }
194
+
195
+ /** Check the file, not only the instance's last acquisition result. */
196
+ owns(): boolean {
197
+ if (this.owned && !this.matches(this.holder())) this.owned = false;
198
+ return this.owned;
199
+ }
200
+
201
+ /** Serialize takeovers, heartbeats and releases across processes too. */
202
+ private guarded<T>(action: () => T): T {
203
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
204
+ return withFileLock(this.file, action);
205
+ }
206
+
187
207
  tryAcquire(now = Date.now()): boolean {
188
- if (this.owned) return true;
189
- for (let attempt = 0; attempt < 2; attempt++) {
190
- try {
191
- fs.mkdirSync(path.dirname(this.file), { recursive: true });
208
+ try {
209
+ return this.guarded(() => {
210
+ const held = this.holder();
211
+ if (this.matches(held)) return (this.owned = true);
212
+ this.owned = false;
213
+ // /reload releases the old instance first. Sharing its PID does not
214
+ // let a concurrent child session steal its still-live lock.
215
+ if (held && !this.isStale(held, now)) return false;
216
+ fs.rmSync(this.file, { force: true });
192
217
  const fd = fs.openSync(this.file, "wx");
193
- const info: LockInfo = { pid: this.pid, hostname: this.hostname, acquiredAt: now, heartbeatAt: now };
194
- fs.writeSync(fd, `${JSON.stringify(info)}\n`);
195
- fs.closeSync(fd);
196
- this.owned = true;
197
- return true;
198
- } catch (error) {
199
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") return false;
200
- const info = this.holder();
201
- // The same process after /reload, a dead process, or a missed heartbeat.
202
- const takeOver = !info || info.pid === this.pid || this.isStale(info, now);
203
- if (!takeOver) return false;
204
218
  try {
205
- fs.rmSync(this.file, { force: true });
206
- } catch {
207
- return false;
219
+ const info: LockInfo = { pid: this.pid, hostname: this.hostname, ownerToken: this.ownerToken, acquiredAt: now, heartbeatAt: now };
220
+ fs.writeSync(fd, `${JSON.stringify(info)}\n`);
221
+ } finally {
222
+ fs.closeSync(fd);
208
223
  }
209
- }
224
+ return (this.owned = true);
225
+ });
226
+ } catch {
227
+ this.owned = false;
228
+ return false;
210
229
  }
211
- return false;
212
230
  }
213
231
 
214
- heartbeat(now = Date.now()): void {
215
- if (!this.owned) return;
216
- const info = this.holder();
217
- if (!info || info.pid !== this.pid) {
218
- // Someone took the lock over (we were considered stale).
219
- this.owned = false;
220
- return;
221
- }
232
+ /** Renew only our own lease; a failed renewal must not permit dispatch. */
233
+ heartbeat(now = Date.now()): boolean {
234
+ if (!this.owned) return false;
222
235
  try {
223
- writeAtomic(this.file, `${JSON.stringify({ ...info, heartbeatAt: now })}\n`);
236
+ return this.guarded(() => {
237
+ const info = this.holder();
238
+ if (!this.matches(info)) {
239
+ this.owned = false;
240
+ return false;
241
+ }
242
+ writeAtomic(this.file, `${JSON.stringify({ ...info, heartbeatAt: now })}\n`);
243
+ return true;
244
+ });
224
245
  } catch {
225
- // a missed heartbeat only matters after a minute
246
+ this.owned = false;
247
+ return false;
226
248
  }
227
249
  }
228
250
 
229
251
  release(): void {
230
252
  if (!this.owned) return;
231
- this.owned = false;
232
- if (this.holder()?.pid === this.pid) {
233
- try {
234
- fs.rmSync(this.file, { force: true });
235
- } catch {
236
- // a stale lock is taken over after a minute anyway
237
- }
253
+ try {
254
+ this.guarded(() => {
255
+ if (this.matches(this.holder())) fs.rmSync(this.file, { force: true });
256
+ });
257
+ } catch {
258
+ // a stale lock is taken over after a minute anyway
259
+ } finally {
260
+ this.owned = false;
238
261
  }
239
262
  }
240
263
  }
@@ -272,7 +295,7 @@ export function ensureGitExclude(cwd: string): void {
272
295
  const file = path.join(common, "info", "exclude");
273
296
  const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
274
297
  const lines = current.split("\n").map((line) => line.trim());
275
- const wanted = ["**/.pi/scheduled_tasks.json", "**/.pi/scheduled_tasks.lock", "**/.pi/scheduled_tasks.json.write.lock"];
298
+ const wanted = ["**/.pi/scheduled_tasks.json", "**/.pi/scheduled_tasks.lock", "**/.pi/scheduled_tasks.json.write.lock", "**/.pi/scheduled_tasks.lock.write.lock"];
276
299
  const missing = wanted.filter((entry) => !lines.includes(entry));
277
300
  if (!missing.length) return;
278
301
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -316,7 +339,15 @@ export class DurableRuntime {
316
339
  }
317
340
 
318
341
  get owner(): boolean {
319
- return this.lock.owned;
342
+ return this.lock.owns();
343
+ }
344
+
345
+ /** Revalidate and renew immediately before a durable fire, even when sync is throttled. */
346
+ canDispatch(): boolean {
347
+ this.lastHeartbeat = Date.now();
348
+ if (this.lock.heartbeat(this.lastHeartbeat)) return true;
349
+ this.dropLoadedJobs();
350
+ return false;
320
351
  }
321
352
 
322
353
  holderPid(): number | undefined {
@@ -341,6 +372,7 @@ export class DurableRuntime {
341
372
  this.lastMaintenance = now;
342
373
  this.store.assertWritable();
343
374
  if (!this.owner) {
375
+ this.dropLoadedJobs();
344
376
  if (this.store.exists()) this.acquireAndLoad(now);
345
377
  return;
346
378
  }
@@ -383,6 +415,10 @@ export class DurableRuntime {
383
415
 
384
416
  /** Record a fire: update the job in the file, or remove it after its last run. */
385
417
  afterFire(job: LoopJob, finished: boolean): void {
418
+ if (!this.owner) {
419
+ this.dropLoadedJobs();
420
+ return;
421
+ }
386
422
  try {
387
423
  if (finished) this.store.remove(job.id);
388
424
  else this.store.upsert(job);
package/index.ts CHANGED
@@ -194,11 +194,12 @@ export default function piCcLoopExtension(pi: ExtensionAPI): void {
194
194
  persist(jobs);
195
195
  refreshStatus(jobs);
196
196
  },
197
- canDispatch: () => {
197
+ canDispatch: (job) => {
198
198
  const ctx = lastCtx;
199
199
  if (!ctx) return false;
200
200
  try {
201
- return ctx.isIdle() && !ctx.hasPendingMessages();
201
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) return false;
202
+ return !job.durable || durable?.canDispatch() === true;
202
203
  } catch {
203
204
  return false;
204
205
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-loop",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
4
4
  "description": "Claude Code compatible /loop for the Pi coding agent: cron-scheduled fixed intervals, self-paced chains, and the same dispatch semantics as Claude Code.",
5
5
  "author": "tarnish233",
6
6
  "type": "module",
package/schedule.ts CHANGED
@@ -149,8 +149,8 @@ export interface SchedulerHooks {
149
149
  afterDispatch?: (job: LoopJob, finished: boolean) => void;
150
150
  /** Called whenever the job set changes, so state can be persisted. */
151
151
  onChange?: (jobs: LoopJob[]) => void;
152
- /** True when the agent is idle and has nothing queued: a tick may go out. */
153
- canDispatch?: () => boolean;
152
+ /** True when idle and this job may fire (durable jobs also need their live lock). */
153
+ canDispatch?: (job: LoopJob) => boolean;
154
154
  /** The offsets to apply to run times. */
155
155
  timing?: () => TimingConfig;
156
156
  /** Max age of a recurring job in ms; 0 disables the limit. */
@@ -321,7 +321,7 @@ export class LoopScheduler {
321
321
  .filter((candidate) => candidate.pending)
322
322
  .sort((a, b) => (a.dueAt ?? 0) - (b.dueAt ?? 0))[0];
323
323
  if (!job) return;
324
- if (this.hooks.canDispatch && !this.hooks.canDispatch()) return;
324
+ if (this.hooks.canDispatch && !this.hooks.canDispatch(job)) return;
325
325
 
326
326
  job.pending = false;
327
327
  job.dueAt = undefined;