@programinglive/dev-workflow-mcp-server 1.13.14 → 1.13.16
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/index.js +20 -15
- package/package.json +1 -1
- package/process-lock.js +63 -6
- package/tools/handler-modules/commit-handlers.js +20 -5
- package/tools/handler-modules/factory-handlers.js +16 -1
package/index.js
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
15
15
|
import { fileURLToPath } from "url";
|
|
16
16
|
import path from "path";
|
|
17
|
-
import { acquireProcessLock, releaseProcessLock, lockWarning } from "./process-lock.js";
|
|
17
|
+
import { acquireProcessLock, releaseProcessLock, lockWarning, projectLockDir } from "./process-lock.js";
|
|
18
18
|
import { exec, setActiveProjectDir } from "./exec.js";
|
|
19
19
|
import { normalizeRequestArgs, shellEscape } from "./utils.js";
|
|
20
20
|
import { createGitFacade } from "./git-facade.js";
|
|
@@ -199,26 +199,31 @@ export async function main() {
|
|
|
199
199
|
// Select request metadata before the first disk write. Creating an idle
|
|
200
200
|
// PID fallback here leaves a phantom session alongside the real sessions.
|
|
201
201
|
safeLog('error', `[${new Date().toISOString()}] Workflow state initialization complete.`);
|
|
202
|
-
|
|
203
|
-
// Penanda proses: 31 Oktober 2026 dua proses MCP hidup bersamaan dan
|
|
204
|
-
// memegang kredensial Factory yang berbeda, sehingga tool yang sama
|
|
205
|
-
// menjawab berbeda tergantung proses mana yang melayaninya. Proses lain
|
|
206
|
-
// TIDAK dibunuh (itu berbahaya); cukup dilaporkan supaya pengguna tahu
|
|
207
|
-
// harus menutup OpenCode dan membukanya lagi.
|
|
208
|
-
try {
|
|
209
|
-
const stateDir = path.dirname(path.resolve(workflowState.stateFile));
|
|
210
|
-
const warning = lockWarning(acquireProcessLock({ dir: stateDir }));
|
|
211
|
-
if (warning) safeLog('error', warning);
|
|
212
|
-
process.once('exit', () => releaseProcessLock({ dir: stateDir }));
|
|
213
|
-
} catch (lockError) {
|
|
214
|
-
safeLog('error', 'Warning: could not record process lock:', lockError.message);
|
|
215
|
-
}
|
|
216
202
|
} catch (error) {
|
|
217
203
|
safeLog('error', `[${new Date().toISOString()}] Warning: Failed to initialize workflow state:`, error.message);
|
|
218
204
|
// Continue anyway - workflow state is optional for MCP to function
|
|
219
205
|
workflowState = new WorkflowState();
|
|
220
206
|
}
|
|
221
207
|
|
|
208
|
+
// Penanda proses - DI LUAR try di atas, dengan sengaja.
|
|
209
|
+
//
|
|
210
|
+
// 31 Oktober 2026: blok ini semula berada di dalam try yang sama dengan
|
|
211
|
+
// inisialisasi state. Saat dua proses MCP start serempak, satu di antaranya
|
|
212
|
+
// gagal menginisialisasi state (berebut berkas yang sama), masuk ke catch, dan
|
|
213
|
+
// penanda prosesnya tidak pernah ditulis. Hasilnya dua proses hidup dengan
|
|
214
|
+
// satu lock file: proses ganda tidak pernah terdeteksi.
|
|
215
|
+
//
|
|
216
|
+
// Lokasinya juga harus di `.state` PROYEK, bukan di direktori user: direktori
|
|
217
|
+
// user bisa berbeda antar proses, sehingga keduanya tidak saling melihat.
|
|
218
|
+
try {
|
|
219
|
+
const lockDir = projectLockDir(workflowState.stateFile);
|
|
220
|
+
const warning = lockWarning(acquireProcessLock({ dir: lockDir }));
|
|
221
|
+
if (warning) safeLog('error', warning);
|
|
222
|
+
process.once('exit', () => releaseProcessLock({ dir: lockDir }));
|
|
223
|
+
} catch (lockError) {
|
|
224
|
+
safeLog('error', 'Warning: could not record process lock:', lockError.message);
|
|
225
|
+
}
|
|
226
|
+
|
|
222
227
|
const transport = new StdioServerTransport();
|
|
223
228
|
// Cover SDK-generated protocol errors as well as tool-handler responses.
|
|
224
229
|
const send = transport.send.bind(transport);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@programinglive/dev-workflow-mcp-server",
|
|
3
|
-
"version": "1.13.
|
|
3
|
+
"version": "1.13.16",
|
|
4
4
|
"description": "MCP server to enforce development workflow discipline",
|
|
5
5
|
"homepage": "https://devworkflow.programinglive.com/",
|
|
6
6
|
"mcpName": "io.github.programinglive/dev-workflow-mcp-server",
|
package/process-lock.js
CHANGED
|
@@ -24,6 +24,41 @@ export function processLockPath({ dir }) {
|
|
|
24
24
|
return path.join(dir, LOCK_FILENAME);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Direktori `.state` milik PROYEK untuk sebuah berkas state.
|
|
29
|
+
*
|
|
30
|
+
* Kenapa bukan `path.dirname(stateFile)` saja: berkas state sesi berada di
|
|
31
|
+
* `.state/users/<user>/`, dan direktori user bisa BERBEDA antar proses (id user
|
|
32
|
+
* dibuat per direktori state). Pada 31 Oktober 2026 dua proses MCP hidup
|
|
33
|
+
* bersamaan tetapi hanya satu lock file yang tercipta, karena masing-masing
|
|
34
|
+
* menulis ke direktori user-nya sendiri - jadi keduanya tidak pernah saling
|
|
35
|
+
* melihat dan peringatan proses ganda tidak muncul.
|
|
36
|
+
*
|
|
37
|
+
* Lock harus satu per PROYEK, bukan per user. Fungsi ini naik sampai menemukan
|
|
38
|
+
* direktori bernama `.state` dan memakai direktori itu. Kalau tidak ada (state
|
|
39
|
+
* di luar tata letak itu), pakai direktori berkasnya sendiri.
|
|
40
|
+
*/
|
|
41
|
+
export function projectLockDir(stateFilePath) {
|
|
42
|
+
const resolved = path.resolve(stateFilePath);
|
|
43
|
+
let current = path.dirname(resolved);
|
|
44
|
+
const { root } = path.parse(current);
|
|
45
|
+
|
|
46
|
+
while (true) {
|
|
47
|
+
if (path.basename(current) === ".state") {
|
|
48
|
+
return current;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const parent = path.dirname(current);
|
|
52
|
+
if (parent === current || current === root) {
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
current = parent;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return path.dirname(resolved);
|
|
60
|
+
}
|
|
61
|
+
|
|
27
62
|
/**
|
|
28
63
|
* Membaca isi lock, atau null bila tidak ada / tidak terbaca.
|
|
29
64
|
*
|
|
@@ -66,7 +101,13 @@ function defaultIsAlive(pid) {
|
|
|
66
101
|
* { acquired: true, tookOverFrom: <pid> } -> pemegang lama sudah mati
|
|
67
102
|
* { acquired: false, holder: {...} } -> ada proses lain yang hidup
|
|
68
103
|
*/
|
|
69
|
-
export function acquireProcessLock({
|
|
104
|
+
export function acquireProcessLock({
|
|
105
|
+
dir,
|
|
106
|
+
pid = process.pid,
|
|
107
|
+
now = Date.now(),
|
|
108
|
+
isAlive = defaultIsAlive,
|
|
109
|
+
writeFileImpl = writeFileSync,
|
|
110
|
+
}) {
|
|
70
111
|
const existing = readProcessLock({ dir });
|
|
71
112
|
|
|
72
113
|
if (existing && existing.pid !== pid && isAlive(existing.pid)) {
|
|
@@ -79,10 +120,17 @@ export function acquireProcessLock({ dir, pid = process.pid, now = Date.now(), i
|
|
|
79
120
|
|
|
80
121
|
try {
|
|
81
122
|
mkdirSync(dir, { recursive: true });
|
|
82
|
-
|
|
83
|
-
} catch {
|
|
84
|
-
// Tidak bisa menulis penanda bukan alasan mematikan server
|
|
85
|
-
|
|
123
|
+
writeFileImpl(processLockPath({ dir }), JSON.stringify(payload), "utf8");
|
|
124
|
+
} catch (error) {
|
|
125
|
+
// Tidak bisa menulis penanda bukan alasan mematikan server, TETAPI juga
|
|
126
|
+
// tidak boleh ditelan: sampai 31 Oktober 2026 kegagalan ini hanya
|
|
127
|
+
// mengembalikan { acquired: true } sehingga sistem tampak sehat padahal
|
|
128
|
+
// tidak ada penanda apa pun di disk. Simpan sebabnya supaya bisa dilaporkan.
|
|
129
|
+
return {
|
|
130
|
+
acquired: true,
|
|
131
|
+
wrote: false,
|
|
132
|
+
error: (error && (error.code || error.message)) || "unknown",
|
|
133
|
+
};
|
|
86
134
|
}
|
|
87
135
|
|
|
88
136
|
return tookOverFrom ? { acquired: true, tookOverFrom } : { acquired: true };
|
|
@@ -118,7 +166,16 @@ function safeHostname() {
|
|
|
118
166
|
* nyata, dan supaya pemanggil bebas memilih tidak menampilkan apa pun.
|
|
119
167
|
*/
|
|
120
168
|
export function lockWarning(result) {
|
|
121
|
-
if (!result
|
|
169
|
+
if (!result) return null;
|
|
170
|
+
|
|
171
|
+
if (result.wrote === false) {
|
|
172
|
+
return (
|
|
173
|
+
`Tidak bisa menulis penanda proses ke ${LOCK_FILENAME} (${result.error}). ` +
|
|
174
|
+
"Deteksi proses MCP ganda tidak akan bekerja sampai ini diperbaiki."
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (result.acquired !== false || !result.holder) return null;
|
|
122
179
|
|
|
123
180
|
const started = Number.isFinite(result.holder.startedAt)
|
|
124
181
|
? new Date(result.holder.startedAt).toISOString()
|
|
@@ -186,9 +186,13 @@ export async function handleCommitAndPush(args, context) {
|
|
|
186
186
|
const lastCommitMessage = await git.getLastCommitMessage();
|
|
187
187
|
const effectiveCommitMessage = lastCommitMessage || providedCommitMessage;
|
|
188
188
|
|
|
189
|
-
// "Tidak ada perubahan" bukan berarti "sudah ter-push"
|
|
190
|
-
//
|
|
191
|
-
//
|
|
189
|
+
// "Tidak ada perubahan" bukan berarti "sudah ter-push", dan juga bukan
|
|
190
|
+
// berarti "panggilan ini sudah commit". HEAD bisa saja berupa commit yang
|
|
191
|
+
// belum pernah dikirim, dan bisa juga sudah terkirim oleh panggilan
|
|
192
|
+
// SEBELUMNYA. Memeriksanya mencegah tool menandai selesai padahal remote
|
|
193
|
+
// masih tertinggal - dan kalimat "Assuming commit and push already
|
|
194
|
+
// completed" (dipakai sampai 31 Oktober 2026) membuat agen percaya
|
|
195
|
+
// pekerjaannya sudah masuk padahal tidak ada yang dikerjakan sama sekali.
|
|
192
196
|
const pushed = await verifyPushed(git, branchForPush);
|
|
193
197
|
if (!pushed.ok) {
|
|
194
198
|
return textResponse(
|
|
@@ -205,9 +209,20 @@ export async function handleCommitAndPush(args, context) {
|
|
|
205
209
|
workflowState.state.currentPhase = isReleaseSatisfied(workflowState.state) ? "ready_to_complete" : "release";
|
|
206
210
|
await workflowState.save();
|
|
207
211
|
|
|
212
|
+
const noActiveTask = workflowState.state.currentPhase === "idle" || !workflowState.state.taskDescription;
|
|
213
|
+
|
|
208
214
|
return textResponse(
|
|
209
|
-
`ℹ️ No changes detected.
|
|
210
|
-
}\
|
|
215
|
+
`ℹ️ No changes detected. No commit was made by this call.\n` +
|
|
216
|
+
`Repo: ${repoName}\nBranch: ${branchForPush} (verified on remote, HEAD ${pushed.head.slice(0, 8)})\n` +
|
|
217
|
+
`Commit terakhir: ${effectiveCommitMessage || "(tidak terbaca)"}\n\n` +
|
|
218
|
+
(noActiveTask
|
|
219
|
+
? `Tidak ada task aktif di state workflow (currentPhase "idle"), jadi memang tidak ada ` +
|
|
220
|
+
`yang diharapkan dari panggilan ini.\n\n`
|
|
221
|
+
: "") +
|
|
222
|
+
`Yang terbukti: commit terakhir sudah ada di remote. Yang TIDAK terbukti: commit itu berisi ` +
|
|
223
|
+
`pekerjaan task ini. Kalau perubahan seharusnya ada, berarti belum ditulis, sudah di-commit ` +
|
|
224
|
+
`oleh panggilan sebelumnya, atau berada di repo/branch lain.\n\n` +
|
|
225
|
+
`Next: Run 'perform_release' to handle the release and push tags.`
|
|
211
226
|
);
|
|
212
227
|
}
|
|
213
228
|
|
|
@@ -134,8 +134,19 @@ export async function handleClaimFactoryTask(args = {}, { workflowState } = {})
|
|
|
134
134
|
// dikerjakan, dan papan terlihat penuh task mati.
|
|
135
135
|
startKeeping(config, session, step);
|
|
136
136
|
|
|
137
|
+
// Arsipkan task lokal yang sedang berjalan, jangan hapus diam-diam.
|
|
138
|
+
//
|
|
139
|
+
// 31 Oktober 2026 claim_factory_task dipanggil di tengah task lain yang baru
|
|
140
|
+
// sampai tahap rilis. `reset()` tanpa archive membuat task itu lenyap: tidak
|
|
141
|
+
// ada entri history, tidak ada peringatan, tidak disebut di jawaban.
|
|
142
|
+
// `start_task` sudah mengarsipkan sejak awal; claim tidak.
|
|
143
|
+
let archivedTask = null;
|
|
137
144
|
if (workflowState) {
|
|
138
|
-
workflowState.reset(
|
|
145
|
+
const archived = workflowState.reset({
|
|
146
|
+
archive: true,
|
|
147
|
+
reason: "Diganti claim_factory_task sebelum task selesai",
|
|
148
|
+
});
|
|
149
|
+
archivedTask = archived?.archivedTask || null;
|
|
139
150
|
workflowState.state.currentPhase = "coding";
|
|
140
151
|
workflowState.state.taskDescription = `[${task.taskKey || task.id}] ${task.title}`;
|
|
141
152
|
workflowState.state.taskType = "other";
|
|
@@ -145,6 +156,10 @@ export async function handleClaimFactoryTask(args = {}, { workflowState } = {})
|
|
|
145
156
|
return textResponse(
|
|
146
157
|
[
|
|
147
158
|
`Claimed [${task.taskKey || task.id}] ${task.title}`,
|
|
159
|
+
archivedTask
|
|
160
|
+
? `⚠️ Task lokal yang belum selesai diarsipkan ke history: "${archivedTask}". ` +
|
|
161
|
+
`Pekerjaannya tidak dihapus, tetapi tidak lagi aktif di sesi ini.`
|
|
162
|
+
: "",
|
|
148
163
|
session.tookOver && session.previousHolder
|
|
149
164
|
? `Took over from ${session.previousHolder.deviceName} (${session.previousHolder.agent}). The reason is recorded on the board.`
|
|
150
165
|
: "",
|