@buihongduc132/pi-acp-agents 0.3.1
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/CHANGELOG.md +45 -0
- package/LICENSE +21 -0
- package/README.md +359 -0
- package/index.ts +1521 -0
- package/package.json +103 -0
- package/skills/pi-acp-agents/SKILL.md +112 -0
- package/src/acp-widget.ts +379 -0
- package/src/adapter-factory.ts +55 -0
- package/src/adapters/acpx.ts +215 -0
- package/src/adapters/base.ts +117 -0
- package/src/adapters/codex.ts +77 -0
- package/src/adapters/custom.ts +14 -0
- package/src/adapters/gemini.ts +66 -0
- package/src/adapters/opencode.ts +101 -0
- package/src/config/config.ts +312 -0
- package/src/config/types.ts +203 -0
- package/src/coordination/alias-resolver.ts +208 -0
- package/src/coordination/coordinator.ts +266 -0
- package/src/coordination/worker-dispatcher.ts +191 -0
- package/src/core/async-executor.ts +149 -0
- package/src/core/circuit-breaker.ts +254 -0
- package/src/core/client.ts +661 -0
- package/src/core/health-monitor.ts +200 -0
- package/src/core/protocol-validator.ts +259 -0
- package/src/core/session-lifecycle.ts +46 -0
- package/src/core/session-manager.ts +64 -0
- package/src/extension-safety.ts +200 -0
- package/src/logger.ts +92 -0
- package/src/management/event-log.ts +31 -0
- package/src/management/governance-store.ts +123 -0
- package/src/management/heartbeat-parser.ts +92 -0
- package/src/management/mailbox-manager.ts +95 -0
- package/src/management/runtime-paths.ts +34 -0
- package/src/management/safe-mkdir.ts +78 -0
- package/src/management/session-archive-store.ts +136 -0
- package/src/management/session-name-store.ts +88 -0
- package/src/management/task-store.ts +260 -0
- package/src/management/worker-store.ts +164 -0
- package/src/public-api.ts +72 -0
- package/src/settings/agent-config-tui.ts +456 -0
- package/src/settings/agents-command.ts +138 -0
- package/src/settings/config.ts +201 -0
- package/src/settings/configure-tui.ts +135 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* safe-mkdir — Ownership-aware directory creation for pi extensions
|
|
3
|
+
*
|
|
4
|
+
* Guarantees that created directories are owned by the current user.
|
|
5
|
+
* Detects root-owned directories (e.g. from sudo test runs) and warns
|
|
6
|
+
* with a clear fix command instead of silently failing later with EACCES.
|
|
7
|
+
*
|
|
8
|
+
* All pi extensions that create directories under ~/.pi/ MUST use this
|
|
9
|
+
* instead of bare `mkdirSync`.
|
|
10
|
+
*/
|
|
11
|
+
// @ts-nocheck
|
|
12
|
+
|
|
13
|
+
import { mkdirSync, statSync } from "node:fs";
|
|
14
|
+
import { createNoopLogger } from "../logger.js";
|
|
15
|
+
|
|
16
|
+
const log = createNoopLogger();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create a directory with current-user ownership guarantee.
|
|
20
|
+
*
|
|
21
|
+
* - If the dir doesn't exist: creates it with mode 0o755
|
|
22
|
+
* - If the dir exists but is owned by another user: warns with fix command
|
|
23
|
+
* - If the dir exists and is owned by current user: no-op
|
|
24
|
+
*
|
|
25
|
+
* @param dirPath - Absolute path to create
|
|
26
|
+
* @param options - Optional mkdirSync options (mode defaults to 0o755)
|
|
27
|
+
*/
|
|
28
|
+
export function safeMkdir(
|
|
29
|
+
dirPath: string,
|
|
30
|
+
options?: { mode?: number },
|
|
31
|
+
): void {
|
|
32
|
+
const mode = options?.mode ?? 0o755;
|
|
33
|
+
mkdirSync(dirPath, { recursive: true, mode });
|
|
34
|
+
|
|
35
|
+
// Ownership check — detect root-owned dirs from sudo runs
|
|
36
|
+
try {
|
|
37
|
+
const stat = statSync(dirPath);
|
|
38
|
+
if (stat.uid !== process.getuid()) {
|
|
39
|
+
console.warn(
|
|
40
|
+
`[pi] WARNING: Directory ${dirPath} is owned by uid ${stat.uid}, ` +
|
|
41
|
+
`but pi runs as uid ${process.getuid()}. ` +
|
|
42
|
+
`Fix: sudo chown -R $(whoami) ${dirPath}`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
} catch (e) {
|
|
46
|
+
// stat failed — dir may not exist yet, harmless
|
|
47
|
+
log.debug("safe-mkdir stat failed", e);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check if a directory is writable by the current user.
|
|
53
|
+
* Returns { ok: true } or { ok: false, reason: string, fix: string }.
|
|
54
|
+
*/
|
|
55
|
+
export function checkDirOwnership(dirPath: string): {
|
|
56
|
+
ok: boolean;
|
|
57
|
+
reason?: string;
|
|
58
|
+
fix?: string;
|
|
59
|
+
} {
|
|
60
|
+
try {
|
|
61
|
+
const stat = statSync(dirPath);
|
|
62
|
+
if (stat.uid !== process.getuid()) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
reason: `owned by uid ${stat.uid}, expected ${process.getuid()}`,
|
|
66
|
+
fix: `sudo chown -R $(whoami) ${dirPath}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return { ok: true };
|
|
70
|
+
} catch (e) {
|
|
71
|
+
log.debug("safe-mkdir checkDirOwnership stat failed", e);
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
reason: `cannot stat ${dirPath}`,
|
|
75
|
+
fix: `mkdir -p ${dirPath}`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { AcpArchivedSessionMetadata, AcpSessionHandle } from "../config/types.js";
|
|
4
|
+
import { ensureRuntimeDir } from "./runtime-paths.js";
|
|
5
|
+
import { createNoopLogger } from "../logger.js";
|
|
6
|
+
|
|
7
|
+
const log = createNoopLogger();
|
|
8
|
+
|
|
9
|
+
interface ArchivePayload {
|
|
10
|
+
sessions: AcpArchivedSessionMetadataRecord[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ParsedArchivePayload {
|
|
14
|
+
sessions: AcpArchivedSessionMetadata[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface AcpArchivedSessionMetadataRecord {
|
|
18
|
+
sessionId: string;
|
|
19
|
+
sessionName?: string;
|
|
20
|
+
agentName: string;
|
|
21
|
+
cwd: string;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
lastActivityAt: string;
|
|
24
|
+
lastResponseAt?: string;
|
|
25
|
+
completedAt?: string;
|
|
26
|
+
disposed: boolean;
|
|
27
|
+
autoClosed?: boolean;
|
|
28
|
+
closeReason?: string;
|
|
29
|
+
model?: string;
|
|
30
|
+
mode?: string;
|
|
31
|
+
loadStatus?: "loadable" | "unloadable" | "unknown";
|
|
32
|
+
lastLoadAttemptAt?: string;
|
|
33
|
+
lastLoadError?: string;
|
|
34
|
+
loadAttemptCount?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_PAYLOAD: ArchivePayload = { sessions: [] };
|
|
38
|
+
|
|
39
|
+
export class SessionArchiveStore {
|
|
40
|
+
constructor(private rootDir?: string) {}
|
|
41
|
+
|
|
42
|
+
get(sessionId: string): AcpArchivedSessionMetadata | undefined {
|
|
43
|
+
return this.read().sessions.find((session) => session.sessionId === sessionId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
upsert(session: AcpSessionHandle | AcpArchivedSessionMetadata): AcpArchivedSessionMetadata {
|
|
47
|
+
const payload = this.readRaw();
|
|
48
|
+
const record = this.toRecord(session);
|
|
49
|
+
const index = payload.sessions.findIndex((entry) => entry.sessionId === record.sessionId);
|
|
50
|
+
if (index >= 0) {
|
|
51
|
+
payload.sessions[index] = record;
|
|
52
|
+
} else {
|
|
53
|
+
payload.sessions.push(record);
|
|
54
|
+
}
|
|
55
|
+
this.writeRaw(payload);
|
|
56
|
+
return this.fromRecord(record);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private get filePath(): string {
|
|
60
|
+
const paths = ensureRuntimeDir(this.rootDir);
|
|
61
|
+
return join(paths.rootDir, "session-archive.json");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private read(): ParsedArchivePayload {
|
|
65
|
+
return {
|
|
66
|
+
sessions: this.readRaw().sessions.map((session) => this.fromRecord(session)),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private readRaw(): ArchivePayload {
|
|
71
|
+
if (!existsSync(this.filePath)) {
|
|
72
|
+
return structuredClone(DEFAULT_PAYLOAD);
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(readFileSync(this.filePath, "utf-8")) as ArchivePayload;
|
|
76
|
+
} catch (e) {
|
|
77
|
+
// File read failed — return default payload
|
|
78
|
+
log.debug("session-archive-store read failed", e);
|
|
79
|
+
return structuredClone(DEFAULT_PAYLOAD);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private writeRaw(payload: ArchivePayload): void {
|
|
84
|
+
try {
|
|
85
|
+
writeFileSync(this.filePath, JSON.stringify(payload, null, 2) + "\n", "utf-8");
|
|
86
|
+
} catch (e) {
|
|
87
|
+
// File read failed — return default payload
|
|
88
|
+
// EACCES or other FS error — silently degrade.
|
|
89
|
+
log.debug("session-archive-store write failed", e);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private toRecord(session: AcpSessionHandle | AcpArchivedSessionMetadata): AcpArchivedSessionMetadataRecord {
|
|
94
|
+
return {
|
|
95
|
+
sessionId: session.sessionId,
|
|
96
|
+
sessionName: session.sessionName,
|
|
97
|
+
agentName: session.agentName,
|
|
98
|
+
cwd: session.cwd,
|
|
99
|
+
createdAt: session.createdAt.toISOString(),
|
|
100
|
+
lastActivityAt: session.lastActivityAt.toISOString(),
|
|
101
|
+
lastResponseAt: session.lastResponseAt?.toISOString(),
|
|
102
|
+
completedAt: session.completedAt?.toISOString(),
|
|
103
|
+
disposed: session.disposed,
|
|
104
|
+
autoClosed: session.autoClosed,
|
|
105
|
+
closeReason: session.closeReason,
|
|
106
|
+
model: session.model,
|
|
107
|
+
mode: session.mode,
|
|
108
|
+
loadStatus: session.loadStatus,
|
|
109
|
+
lastLoadAttemptAt: session.lastLoadAttemptAt,
|
|
110
|
+
lastLoadError: session.lastLoadError,
|
|
111
|
+
loadAttemptCount: session.loadAttemptCount,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private fromRecord(record: AcpArchivedSessionMetadataRecord): AcpArchivedSessionMetadata {
|
|
116
|
+
return {
|
|
117
|
+
sessionId: record.sessionId,
|
|
118
|
+
sessionName: record.sessionName,
|
|
119
|
+
agentName: record.agentName,
|
|
120
|
+
cwd: record.cwd,
|
|
121
|
+
createdAt: new Date(record.createdAt),
|
|
122
|
+
lastActivityAt: new Date(record.lastActivityAt),
|
|
123
|
+
lastResponseAt: record.lastResponseAt ? new Date(record.lastResponseAt) : undefined,
|
|
124
|
+
completedAt: record.completedAt ? new Date(record.completedAt) : undefined,
|
|
125
|
+
disposed: record.disposed,
|
|
126
|
+
autoClosed: record.autoClosed,
|
|
127
|
+
closeReason: record.closeReason,
|
|
128
|
+
model: record.model,
|
|
129
|
+
mode: record.mode,
|
|
130
|
+
loadStatus: record.loadStatus,
|
|
131
|
+
lastLoadAttemptAt: record.lastLoadAttemptAt,
|
|
132
|
+
lastLoadError: record.lastLoadError,
|
|
133
|
+
loadAttemptCount: record.loadAttemptCount,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { ensureRuntimeDir } from "./runtime-paths.js";
|
|
3
|
+
import { createNoopLogger } from "../logger.js";
|
|
4
|
+
|
|
5
|
+
const log = createNoopLogger();
|
|
6
|
+
|
|
7
|
+
interface SessionNameRecord {
|
|
8
|
+
sessionName: string;
|
|
9
|
+
sessionId: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface SessionNameRegistryPayload {
|
|
13
|
+
mappings: SessionNameRecord[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEFAULT_PAYLOAD: SessionNameRegistryPayload = { mappings: [] };
|
|
17
|
+
|
|
18
|
+
function normalizeSessionName(sessionName: string): string {
|
|
19
|
+
const normalized = sessionName.trim();
|
|
20
|
+
if (normalized === "") {
|
|
21
|
+
throw new Error("session_name is required");
|
|
22
|
+
}
|
|
23
|
+
return normalized;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class SessionNameStore {
|
|
27
|
+
constructor(
|
|
28
|
+
private rootDir?: string,
|
|
29
|
+
private options?: { treatAsRuntimeDir?: boolean },
|
|
30
|
+
) {}
|
|
31
|
+
|
|
32
|
+
getSessionId(sessionName: string): string | undefined {
|
|
33
|
+
const normalizedName = normalizeSessionName(sessionName);
|
|
34
|
+
return this.read().mappings.find((entry) => entry.sessionName === normalizedName)?.sessionId;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
getName(sessionId: string): string | undefined {
|
|
38
|
+
return this.read().mappings.find((entry) => entry.sessionId === sessionId)?.sessionName;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
register(sessionName: string, sessionId: string): SessionNameRecord {
|
|
42
|
+
const normalizedName = normalizeSessionName(sessionName);
|
|
43
|
+
const payload = this.read();
|
|
44
|
+
const existingByName = payload.mappings.find((entry) => entry.sessionName === normalizedName);
|
|
45
|
+
if (existingByName && existingByName.sessionId !== sessionId) {
|
|
46
|
+
throw new Error(`Session name "${normalizedName}" is already assigned to session "${existingByName.sessionId}".`);
|
|
47
|
+
}
|
|
48
|
+
const existingBySession = payload.mappings.find((entry) => entry.sessionId === sessionId);
|
|
49
|
+
if (existingBySession && existingBySession.sessionName !== normalizedName) {
|
|
50
|
+
throw new Error(`Session "${sessionId}" is already assigned friendly name "${existingBySession.sessionName}".`);
|
|
51
|
+
}
|
|
52
|
+
const record = existingByName ?? existingBySession ?? { sessionName: normalizedName, sessionId };
|
|
53
|
+
if (!existingByName && !existingBySession) {
|
|
54
|
+
payload.mappings.push(record);
|
|
55
|
+
this.write(payload);
|
|
56
|
+
}
|
|
57
|
+
return record;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private get filePath(): string {
|
|
61
|
+
if (this.options?.treatAsRuntimeDir && this.rootDir) {
|
|
62
|
+
return `${this.rootDir}/session-name-registry.json`;
|
|
63
|
+
}
|
|
64
|
+
return ensureRuntimeDir(this.rootDir).sessionNameRegistryFile;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private read(): SessionNameRegistryPayload {
|
|
68
|
+
if (!existsSync(this.filePath)) return structuredClone(DEFAULT_PAYLOAD);
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(readFileSync(this.filePath, "utf8")) as SessionNameRegistryPayload;
|
|
71
|
+
return { mappings: Array.isArray(parsed.mappings) ? parsed.mappings.filter((entry) => entry?.sessionName && entry?.sessionId) : [] };
|
|
72
|
+
} catch (e) {
|
|
73
|
+
// File read failed — return default payload
|
|
74
|
+
log.debug("session-name-store read failed", e);
|
|
75
|
+
return structuredClone(DEFAULT_PAYLOAD);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private write(payload: SessionNameRegistryPayload): void {
|
|
80
|
+
try {
|
|
81
|
+
writeFileSync(this.filePath, JSON.stringify(payload, null, 2) + "\n", "utf8");
|
|
82
|
+
} catch (e) {
|
|
83
|
+
// File read failed — return default payload
|
|
84
|
+
// EACCES or other FS error — silently degrade.
|
|
85
|
+
log.debug("session-name-store write failed", e);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { ensureRuntimeDir } from "./runtime-paths.js";
|
|
3
|
+
import { createNoopLogger } from "../logger.js";
|
|
4
|
+
import type { AcpTaskPriority } from "../config/types.js";
|
|
5
|
+
|
|
6
|
+
const log = createNoopLogger();
|
|
7
|
+
|
|
8
|
+
export type AcpTaskStatus = "pending" | "in_progress" | "completed" | "deleted";
|
|
9
|
+
|
|
10
|
+
export interface AcpTaskRecord {
|
|
11
|
+
id: string;
|
|
12
|
+
subject: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
status: AcpTaskStatus;
|
|
15
|
+
assignee?: string;
|
|
16
|
+
result?: string;
|
|
17
|
+
blockedBy: string[];
|
|
18
|
+
blocks: string[];
|
|
19
|
+
priority: AcpTaskPriority;
|
|
20
|
+
metadata: Record<string, unknown>;
|
|
21
|
+
createdAt: string;
|
|
22
|
+
updatedAt: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface AcpTaskStorePayload {
|
|
26
|
+
nextId: number;
|
|
27
|
+
tasks: AcpTaskRecord[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_PAYLOAD: AcpTaskStorePayload = {
|
|
31
|
+
nextId: 1,
|
|
32
|
+
tasks: [],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export class AcpTaskStore {
|
|
36
|
+
constructor(private rootDir?: string) {}
|
|
37
|
+
|
|
38
|
+
list(options?: { status?: AcpTaskStatus; includeDeleted?: boolean }): AcpTaskRecord[] {
|
|
39
|
+
const payload = this.read();
|
|
40
|
+
return payload.tasks.filter((task) => {
|
|
41
|
+
if (!options?.includeDeleted && task.status === "deleted") return false;
|
|
42
|
+
if (options?.status && task.status !== options.status) return false;
|
|
43
|
+
return true;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get(id: string): AcpTaskRecord | undefined {
|
|
48
|
+
return this.read().tasks.find((task) => task.id === id);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
create(input: { subject: string; description?: string; assignee?: string; deps?: string[] }): AcpTaskRecord {
|
|
52
|
+
const payload = this.read();
|
|
53
|
+
const now = new Date().toISOString();
|
|
54
|
+
const task: AcpTaskRecord = {
|
|
55
|
+
id: String(payload.nextId++),
|
|
56
|
+
subject: input.subject,
|
|
57
|
+
description: input.description,
|
|
58
|
+
assignee: input.assignee,
|
|
59
|
+
status: "pending",
|
|
60
|
+
blockedBy: input.deps ?? [],
|
|
61
|
+
blocks: [],
|
|
62
|
+
priority: "normal",
|
|
63
|
+
metadata: {},
|
|
64
|
+
createdAt: now,
|
|
65
|
+
updatedAt: now,
|
|
66
|
+
};
|
|
67
|
+
payload.tasks.push(task);
|
|
68
|
+
this.write(payload);
|
|
69
|
+
return task;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
update(id: string, mutate: (task: AcpTaskRecord) => void): AcpTaskRecord {
|
|
73
|
+
const payload = this.read();
|
|
74
|
+
const task = payload.tasks.find((item) => item.id === id);
|
|
75
|
+
if (!task) throw new Error(`Task \"${id}\" not found`);
|
|
76
|
+
mutate(task);
|
|
77
|
+
task.updatedAt = new Date().toISOString();
|
|
78
|
+
this.write(payload);
|
|
79
|
+
return task;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
clear(mode: "completed" | "all" = "completed"): { removed: number; remaining: number } {
|
|
83
|
+
const payload = this.read();
|
|
84
|
+
const before = payload.tasks.length;
|
|
85
|
+
if (mode === "all") {
|
|
86
|
+
payload.tasks = [];
|
|
87
|
+
} else {
|
|
88
|
+
payload.tasks = payload.tasks.filter((task) => task.status !== "completed" && task.status !== "deleted");
|
|
89
|
+
}
|
|
90
|
+
const removed = before - payload.tasks.length;
|
|
91
|
+
this.write(payload);
|
|
92
|
+
return { removed, remaining: payload.tasks.length };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Bulk update tasks matching a filter. Returns updated tasks. */
|
|
96
|
+
updateWhere(filter: string, mutate: (task: AcpTaskRecord) => void): AcpTaskRecord[] {
|
|
97
|
+
const payload = this.read();
|
|
98
|
+
const updated: AcpTaskRecord[] = [];
|
|
99
|
+
for (const task of payload.tasks) {
|
|
100
|
+
let matches = false;
|
|
101
|
+
if (filter === "completed" && task.status === "completed") matches = true;
|
|
102
|
+
else if (filter === "pending" && task.status === "pending") matches = true;
|
|
103
|
+
else if (filter === "in_progress" && task.status === "in_progress") matches = true;
|
|
104
|
+
else if (filter === "" || filter === "all") matches = true;
|
|
105
|
+
if (matches) {
|
|
106
|
+
mutate(task);
|
|
107
|
+
task.updatedAt = new Date().toISOString();
|
|
108
|
+
updated.push(task);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (updated.length > 0) this.write(payload);
|
|
112
|
+
return updated;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** List tasks with full dependency graph details. */
|
|
116
|
+
listWithDetails(): AcpTaskRecord[] {
|
|
117
|
+
return this.list({ includeDeleted: true });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Create task with priority + metadata support (M3, M5) */
|
|
121
|
+
createWithPriority(input: { subject: string; description?: string; assignee?: string; priority?: AcpTaskPriority; blockedBy?: string[] }): AcpTaskRecord {
|
|
122
|
+
const payload = this.read();
|
|
123
|
+
const now = new Date().toISOString();
|
|
124
|
+
const task: AcpTaskRecord = {
|
|
125
|
+
id: String(payload.nextId++),
|
|
126
|
+
subject: input.subject,
|
|
127
|
+
description: input.description,
|
|
128
|
+
assignee: input.assignee,
|
|
129
|
+
status: "pending",
|
|
130
|
+
blockedBy: input.blockedBy ?? [],
|
|
131
|
+
blocks: [],
|
|
132
|
+
priority: input.priority ?? "normal",
|
|
133
|
+
metadata: {},
|
|
134
|
+
createdAt: now,
|
|
135
|
+
updatedAt: now,
|
|
136
|
+
};
|
|
137
|
+
// Maintain reverse edges
|
|
138
|
+
for (const depId of task.blockedBy) {
|
|
139
|
+
const dep = payload.tasks.find((t) => t.id === depId);
|
|
140
|
+
if (dep && !dep.blocks.includes(task.id)) dep.blocks.push(task.id);
|
|
141
|
+
}
|
|
142
|
+
payload.tasks.push(task);
|
|
143
|
+
this.write(payload);
|
|
144
|
+
return task;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** DFS cycle detection — returns path if cycle found, null otherwise (M5) */
|
|
148
|
+
findDependencyPath(fromId: string, toId: string): string[] | null {
|
|
149
|
+
const tasks = this.read().tasks;
|
|
150
|
+
const visited = new Set<string>();
|
|
151
|
+
const path: string[] = [];
|
|
152
|
+
|
|
153
|
+
function dfs(currentId: string): boolean {
|
|
154
|
+
if (currentId === toId) {
|
|
155
|
+
path.push(currentId);
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
if (visited.has(currentId)) return false;
|
|
159
|
+
visited.add(currentId);
|
|
160
|
+
path.push(currentId);
|
|
161
|
+
const task = tasks.find((t) => t.id === currentId);
|
|
162
|
+
if (task) {
|
|
163
|
+
for (const depId of task.blockedBy) {
|
|
164
|
+
if (dfs(depId)) return true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
path.pop();
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return dfs(fromId) ? path : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Check if a task is blocked by incomplete dependencies (M5, M3) */
|
|
175
|
+
isTaskBlocked(taskId: string): { blocked: boolean; blockedBy: string[] } {
|
|
176
|
+
const tasks = this.read().tasks;
|
|
177
|
+
const task = tasks.find((t) => t.id === taskId);
|
|
178
|
+
if (!task || task.blockedBy.length === 0) return { blocked: false, blockedBy: [] };
|
|
179
|
+
const incompleteDeps = task.blockedBy.filter((depId) => {
|
|
180
|
+
const dep = tasks.find((t) => t.id === depId);
|
|
181
|
+
return !dep || dep.status !== "completed";
|
|
182
|
+
});
|
|
183
|
+
return { blocked: incompleteDeps.length > 0, blockedBy: incompleteDeps };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Auto-claim next available task for a worker (M3) */
|
|
187
|
+
claimNextAvailable(workerName: string, options?: { excludeTaskIds?: string[] }): AcpTaskRecord | null {
|
|
188
|
+
const PRIORITY_ORDER: AcpTaskPriority[] = ["urgent", "high", "normal", "low"];
|
|
189
|
+
const payload = this.read();
|
|
190
|
+
|
|
191
|
+
const sorted = [...payload.tasks].sort((a, b) => {
|
|
192
|
+
const pi = PRIORITY_ORDER.indexOf(a.priority);
|
|
193
|
+
const bi = PRIORITY_ORDER.indexOf(b.priority);
|
|
194
|
+
if (pi !== bi) return pi - bi;
|
|
195
|
+
return parseInt(a.id) - parseInt(b.id);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const exclude = new Set(options?.excludeTaskIds ?? []);
|
|
199
|
+
|
|
200
|
+
for (const task of sorted) {
|
|
201
|
+
if (task.status !== "pending") continue;
|
|
202
|
+
if (task.assignee) continue;
|
|
203
|
+
if (exclude.has(task.id)) continue;
|
|
204
|
+
// Check blocked
|
|
205
|
+
const incompleteDeps = task.blockedBy.filter((depId) => {
|
|
206
|
+
const dep = payload.tasks.find((t) => t.id === depId);
|
|
207
|
+
return !dep || dep.status !== "completed";
|
|
208
|
+
});
|
|
209
|
+
if (incompleteDeps.length > 0) continue;
|
|
210
|
+
// Check retry exhausted
|
|
211
|
+
if (task.metadata?.retryExhausted === true) continue;
|
|
212
|
+
// Check cooldown
|
|
213
|
+
if (
|
|
214
|
+
task.metadata?.cooldownUntil &&
|
|
215
|
+
new Date(task.metadata.cooldownUntil as string) > new Date()
|
|
216
|
+
)
|
|
217
|
+
continue;
|
|
218
|
+
// Claim
|
|
219
|
+
task.assignee = workerName;
|
|
220
|
+
task.status = "in_progress";
|
|
221
|
+
task.metadata.claimedAt = new Date().toISOString();
|
|
222
|
+
task.updatedAt = new Date().toISOString();
|
|
223
|
+
this.write(payload);
|
|
224
|
+
return task;
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private read(): AcpTaskStorePayload {
|
|
230
|
+
try {
|
|
231
|
+
const paths = ensureRuntimeDir(this.rootDir);
|
|
232
|
+
if (!existsSync(paths.tasksFile)) {
|
|
233
|
+
return structuredClone(DEFAULT_PAYLOAD);
|
|
234
|
+
}
|
|
235
|
+
const parsed = JSON.parse(readFileSync(paths.tasksFile, "utf-8")) as AcpTaskStorePayload;
|
|
236
|
+
// Migration: add defaults for legacy records
|
|
237
|
+
for (const task of parsed.tasks) {
|
|
238
|
+
if (!task.blocks) task.blocks = [];
|
|
239
|
+
if (!task.priority) task.priority = "normal";
|
|
240
|
+
if (!task.metadata) task.metadata = {};
|
|
241
|
+
}
|
|
242
|
+
return parsed;
|
|
243
|
+
} catch (e) {
|
|
244
|
+
// File read failed — return default payload
|
|
245
|
+
log.debug("task-store read failed", e);
|
|
246
|
+
return structuredClone(DEFAULT_PAYLOAD);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private write(payload: AcpTaskStorePayload): void {
|
|
251
|
+
try {
|
|
252
|
+
const paths = ensureRuntimeDir(this.rootDir);
|
|
253
|
+
writeFileSync(paths.tasksFile, JSON.stringify(payload, null, 2) + "\n", "utf-8");
|
|
254
|
+
} catch (e) {
|
|
255
|
+
// File read failed — return default payload
|
|
256
|
+
// EACCES or other FS error — silently degrade. Tasks are non-critical runtime state.
|
|
257
|
+
log.debug("task-store write failed", e);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|