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 +9 -0
- package/durable.ts +76 -40
- package/index.ts +3 -2
- package/package.json +1 -1
- package/schedule.ts +3 -3
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
246
|
+
this.owned = false;
|
|
247
|
+
return false;
|
|
226
248
|
}
|
|
227
249
|
}
|
|
228
250
|
|
|
229
251
|
release(): void {
|
|
230
252
|
if (!this.owned) return;
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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;
|